Merge remote-tracking branch 'origin/develop' into feat/android/choose-download-location

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
Lance Chant
2026-07-17 13:50:57 +02:00
706 changed files with 164712 additions and 14657 deletions

View File

@@ -0,0 +1,670 @@
/**
* Audio Storage Module
*
* Unified storage manager for audio files supporting:
* - Look-ahead cache (auto-managed, ephemeral, stored in cache directory)
* - Future: Full music downloads (user-initiated, permanent, stored in documents)
*
* getLocalPath() checks permanent storage first, then cache.
*/
import { EventEmitter } from "eventemitter3";
import { Directory, File, Paths } from "expo-file-system";
import type { EventSubscription } from "expo-modules-core";
import type {
DownloadCompleteEvent as BGDownloadCompleteEvent,
DownloadErrorEvent as BGDownloadErrorEvent,
} from "@/modules";
import { BackgroundDownloader } from "@/modules";
import { storage } from "@/utils/mmkv";
import type {
AudioStorageIndex,
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadOptions,
StoredTrackInfo,
} from "./types";
// Storage keys
const AUDIO_STORAGE_INDEX_KEY = "audio_storage.v1.json";
// Directory names
const AUDIO_CACHE_DIR = "streamyfin-audio-cache";
const AUDIO_PERMANENT_DIR = "streamyfin-audio";
// Default limits
const DEFAULT_MAX_CACHE_TRACKS = 10;
const DEFAULT_MAX_CACHE_SIZE_BYTES = 500 * 1024 * 1024; // 500MB
// Configurable limits (can be updated at runtime)
let configuredMaxCacheSizeBytes = DEFAULT_MAX_CACHE_SIZE_BYTES;
// Event emitter for notifying about download completion
class AudioStorageEventEmitter extends EventEmitter<{
complete: (event: DownloadCompleteEvent) => void;
error: (event: DownloadErrorEvent) => void;
}> {}
export const audioStorageEvents = new AudioStorageEventEmitter();
// Track active downloads: taskId -> { itemId, permanent }
const activeDownloads = new Map<
number,
{ itemId: string; permanent: boolean }
>();
// Track items being downloaded by itemId for quick lookup
const downloadingItems = new Set<string>();
// Track permanent downloads separately for UI indicator
const permanentDownloadingItems = new Set<string>();
// Cached index (loaded from storage on init)
let storageIndex: AudioStorageIndex | null = null;
// Directories (initialized on first use)
let cacheDir: Directory | null = null;
let permanentDir: Directory | null = null;
// Event listener subscriptions (for cleanup)
let _completeSubscription: EventSubscription | null = null;
let _errorSubscription: EventSubscription | null = null;
let listenersSetup = false;
/**
* Get the storage index from MMKV
*/
function getStorageIndex(): AudioStorageIndex {
if (storageIndex) {
return storageIndex;
}
try {
const data = storage.getString(AUDIO_STORAGE_INDEX_KEY);
if (data) {
storageIndex = JSON.parse(data) as AudioStorageIndex;
return storageIndex;
}
} catch {
// Ignore parse errors
}
storageIndex = {
tracks: {},
totalCacheSize: 0,
totalPermanentSize: 0,
};
return storageIndex;
}
/**
* Save the storage index to MMKV
*/
function saveStorageIndex(): void {
if (storageIndex) {
try {
storage.set(AUDIO_STORAGE_INDEX_KEY, JSON.stringify(storageIndex));
} catch {
// Ignore save errors
}
}
}
/**
* Ensure directories exist
*/
async function ensureDirectories(): Promise<void> {
try {
if (!cacheDir) {
cacheDir = new Directory(Paths.cache, AUDIO_CACHE_DIR);
if (!cacheDir.exists) {
await cacheDir.create();
}
}
if (!permanentDir) {
permanentDir = new Directory(Paths.document, AUDIO_PERMANENT_DIR);
if (!permanentDir.exists) {
await permanentDir.create();
}
}
} catch (error) {
console.warn("[AudioStorage] Failed to create directories:", error);
}
}
/**
* Set the maximum cache size in megabytes
* Call this when settings change
*/
export function setMaxCacheSizeMB(sizeMB: number): void {
configuredMaxCacheSizeBytes = sizeMB * 1024 * 1024;
console.log(
`[AudioStorage] Max cache size set to ${sizeMB}MB (${configuredMaxCacheSizeBytes} bytes)`,
);
}
/**
* Initialize audio storage - call this on app startup
*/
export async function initAudioStorage(): Promise<void> {
console.log("[AudioStorage] Initializing...");
try {
await ensureDirectories();
getStorageIndex();
setupEventListeners();
console.log("[AudioStorage] Initialization complete");
} catch (error) {
console.warn("[AudioStorage] Initialization error:", error);
}
}
/**
* Set up BackgroundDownloader event listeners
* Safe to call multiple times - will only set up once
*/
function setupEventListeners(): void {
// Prevent duplicate listeners
if (listenersSetup) return;
listenersSetup = true;
try {
console.log("[AudioStorage] Setting up event listeners...");
_completeSubscription = BackgroundDownloader.addCompleteListener(
(event: BGDownloadCompleteEvent) => {
console.log(
`[AudioStorage] Complete event received: taskId=${event.taskId}, activeDownloads=${JSON.stringify([...activeDownloads.entries()])}`,
);
const downloadInfo = activeDownloads.get(event.taskId);
if (!downloadInfo) {
console.log(
`[AudioStorage] Ignoring complete event for unknown taskId: ${event.taskId}`,
);
return; // Not an audio download
}
handleDownloadComplete(event, downloadInfo);
},
);
_errorSubscription = BackgroundDownloader.addErrorListener(
(event: BGDownloadErrorEvent) => {
console.log(
`[AudioStorage] Error event received: taskId=${event.taskId}, error=${event.error}`,
);
const downloadInfo = activeDownloads.get(event.taskId);
if (!downloadInfo) return; // Not an audio download
handleDownloadError(event, downloadInfo);
},
);
console.log("[AudioStorage] Event listeners set up successfully");
} catch (error) {
console.warn("[AudioStorage] Failed to setup event listeners:", error);
listenersSetup = false;
}
}
/**
* Handle download completion
*/
async function handleDownloadComplete(
event: BGDownloadCompleteEvent,
downloadInfo: { itemId: string; permanent: boolean },
): Promise<void> {
const { itemId, permanent } = downloadInfo;
try {
const file = new File(`file://${event.filePath}`);
const fileInfo = file.info();
const size = fileInfo.size || 0;
const index = getStorageIndex();
// Add to index
const trackInfo: StoredTrackInfo = {
itemId,
localPath: event.filePath,
size,
storedAt: Date.now(),
permanent,
};
index.tracks[itemId] = trackInfo;
if (permanent) {
index.totalPermanentSize += size;
} else {
index.totalCacheSize += size;
}
saveStorageIndex();
console.log(
`[AudioStorage] Downloaded ${itemId} (${(size / 1024 / 1024).toFixed(1)}MB, permanent=${permanent})`,
);
// Emit completion event
audioStorageEvents.emit("complete", {
itemId,
localPath: event.filePath,
permanent,
});
// Clean up tracking
activeDownloads.delete(event.taskId);
downloadingItems.delete(itemId);
permanentDownloadingItems.delete(itemId);
// Evict old cache if needed (only for cache downloads)
if (!permanent) {
evictCacheIfNeeded().catch(() => {
// Ignore eviction errors
});
}
} catch (error) {
console.error(`[AudioStorage] Error handling download complete:`, error);
activeDownloads.delete(event.taskId);
downloadingItems.delete(itemId);
permanentDownloadingItems.delete(itemId);
}
}
/**
* Handle download error
*/
function handleDownloadError(
event: BGDownloadErrorEvent,
downloadInfo: { itemId: string; permanent: boolean },
): void {
const { itemId } = downloadInfo;
console.error(`[AudioStorage] Download failed for ${itemId}:`, event.error);
audioStorageEvents.emit("error", {
itemId,
error: event.error,
});
activeDownloads.delete(event.taskId);
downloadingItems.delete(itemId);
permanentDownloadingItems.delete(itemId);
}
/**
* Get the local file path for a track if it exists
* Checks permanent storage first, then cache
* Returns the path WITH file:// prefix for TrackPlayer
*/
export function getLocalPath(itemId: string | undefined): string | null {
if (!itemId) return null;
try {
const index = getStorageIndex();
const info = index.tracks[itemId];
if (info) {
// Verify file still exists (File constructor needs file:// URI)
try {
const fileUri = info.localPath.startsWith("file://")
? info.localPath
: `file://${info.localPath}`;
const file = new File(fileUri);
if (file.exists) {
// Return the URI with file:// prefix for TrackPlayer
return fileUri;
}
} catch {
// File doesn't exist, remove from index
if (info.permanent) {
index.totalPermanentSize -= info.size;
} else {
index.totalCacheSize -= info.size;
}
delete index.tracks[itemId];
saveStorageIndex();
}
}
} catch {
// Ignore errors
}
return null;
}
/**
* Check if a track is currently being downloaded (any type)
*/
export function isDownloading(itemId: string | undefined): boolean {
if (!itemId) return false;
return downloadingItems.has(itemId);
}
/**
* Check if a track is currently being permanently downloaded (user-initiated)
* Use this for UI indicators - we don't want to show spinners for auto-caching
*/
export function isPermanentDownloading(itemId: string | undefined): boolean {
if (!itemId) return false;
return permanentDownloadingItems.has(itemId);
}
/**
* Check if a track is permanently downloaded (not just cached)
*/
export function isPermanentlyDownloaded(itemId: string | undefined): boolean {
if (!itemId) return false;
try {
const index = getStorageIndex();
const info = index.tracks[itemId];
if (info?.permanent) {
// Verify file still exists
try {
const fileUri = info.localPath.startsWith("file://")
? info.localPath
: `file://${info.localPath}`;
const file = new File(fileUri);
if (file.exists) {
return true;
}
} catch {
// File doesn't exist
}
}
} catch {
// Ignore errors
}
return false;
}
/**
* Check if a track is cached (not permanently downloaded)
*/
export function isCached(itemId: string | undefined): boolean {
if (!itemId) return false;
try {
const index = getStorageIndex();
const info = index.tracks[itemId];
if (info && !info.permanent) {
// Verify file still exists
try {
const fileUri = info.localPath.startsWith("file://")
? info.localPath
: `file://${info.localPath}`;
const file = new File(fileUri);
if (file.exists) {
return true;
}
// File doesn't exist - clean up stale index entry
console.log(
`[AudioStorage] Cleaning up stale cache entry: ${itemId} (file missing)`,
);
index.totalCacheSize -= info.size;
delete index.tracks[itemId];
saveStorageIndex();
} catch {
// File check failed - clean up stale index entry
index.totalCacheSize -= info.size;
delete index.tracks[itemId];
saveStorageIndex();
}
}
} catch {
// Ignore errors
}
return false;
}
/**
* Download a track to storage
* @param itemId - Jellyfin item ID
* @param url - Stream URL to download from
* @param options - Download options (permanent: true for user downloads, false for cache)
*/
export async function downloadTrack(
itemId: string,
url: string,
options: DownloadOptions = { permanent: false },
): Promise<void> {
const { permanent } = options;
// Skip if already downloading
if (isDownloading(itemId)) {
return;
}
// Skip if already permanently downloaded
if (isPermanentlyDownloaded(itemId)) {
return;
}
// If requesting permanent download and file is only cached, delete cached version first
if (permanent && isCached(itemId)) {
console.log(
`[AudioStorage] Upgrading cached track to permanent: ${itemId}`,
);
await deleteTrack(itemId);
}
// Skip if already cached and not requesting permanent
if (!permanent && getLocalPath(itemId)) {
return;
}
// Ensure listeners are set up
setupEventListeners();
await ensureDirectories();
const targetDir = permanent ? permanentDir : cacheDir;
if (!targetDir) {
console.warn("[AudioStorage] Target directory not initialized");
return;
}
// Use the actual container format as extension, fallback to m4a
const extension = options.container?.toLowerCase() || "m4a";
const filename = `${itemId}.${extension}`;
const destinationPath =
`${targetDir.uri.replace(/\/$/, "")}/${filename}`.replace("file://", "");
console.log(
`[AudioStorage] Starting download: ${itemId} (permanent=${permanent})`,
);
try {
downloadingItems.add(itemId);
if (permanent) {
permanentDownloadingItems.add(itemId);
}
const taskId = await BackgroundDownloader.startDownload(
url,
destinationPath,
);
activeDownloads.set(taskId, { itemId, permanent });
console.log(
`[AudioStorage] Download started with taskId=${taskId}, tracking ${activeDownloads.size} downloads`,
);
} catch (error) {
console.error(`[AudioStorage] Failed to start download:`, error);
downloadingItems.delete(itemId);
permanentDownloadingItems.delete(itemId);
}
}
/**
* Cancel a download in progress
*/
export function cancelDownload(itemId: string): void {
for (const [taskId, info] of activeDownloads.entries()) {
if (info.itemId === itemId) {
try {
BackgroundDownloader.cancelDownload(taskId);
} catch {
// Ignore cancel errors
}
activeDownloads.delete(taskId);
downloadingItems.delete(itemId);
permanentDownloadingItems.delete(itemId);
console.log(`[AudioStorage] Cancelled download: ${itemId}`);
break;
}
}
}
/**
* Delete a stored track
*/
export async function deleteTrack(itemId: string): Promise<void> {
const index = getStorageIndex();
const info = index.tracks[itemId];
if (!info) return;
try {
const file = new File(info.localPath);
if (file.exists) {
await file.delete();
}
} catch (error) {
console.warn(`[AudioStorage] Failed to delete file:`, error);
}
if (info.permanent) {
index.totalPermanentSize -= info.size;
} else {
index.totalCacheSize -= info.size;
}
delete index.tracks[itemId];
saveStorageIndex();
console.log(`[AudioStorage] Deleted track: ${itemId}`);
}
/**
* Evict old cache entries if limits are exceeded
*/
async function evictCacheIfNeeded(
maxTracks: number = DEFAULT_MAX_CACHE_TRACKS,
maxSizeBytes: number = configuredMaxCacheSizeBytes,
): Promise<void> {
const index = getStorageIndex();
// Get all cache entries sorted by storedAt (oldest first)
const cacheEntries = Object.values(index.tracks)
.filter((t) => !t.permanent)
.sort((a, b) => a.storedAt - b.storedAt);
// Evict if over track limit or size limit
while (
cacheEntries.length > maxTracks ||
index.totalCacheSize > maxSizeBytes
) {
const oldest = cacheEntries.shift();
if (!oldest) break;
console.log(
`[AudioStorage] Evicting cache entry: ${oldest.itemId} (${(oldest.size / 1024 / 1024).toFixed(1)}MB)`,
);
try {
const file = new File(oldest.localPath);
if (file.exists) {
await file.delete();
}
} catch {
// Ignore deletion errors
}
index.totalCacheSize -= oldest.size;
delete index.tracks[oldest.itemId];
}
saveStorageIndex();
}
/**
* Clear all cached tracks (keeps permanent downloads)
*/
export async function clearCache(): Promise<void> {
const index = getStorageIndex();
const cacheEntries = Object.values(index.tracks).filter((t) => !t.permanent);
for (const entry of cacheEntries) {
try {
const file = new File(entry.localPath);
if (file.exists) {
await file.delete();
}
} catch {
// Ignore deletion errors
}
delete index.tracks[entry.itemId];
}
index.totalCacheSize = 0;
saveStorageIndex();
console.log(`[AudioStorage] Cache cleared`);
}
/**
* Clear all permanent downloads (keeps cache)
*/
export async function clearPermanentDownloads(): Promise<void> {
const index = getStorageIndex();
const permanentEntries = Object.values(index.tracks).filter(
(t) => t.permanent,
);
for (const entry of permanentEntries) {
try {
const fileUri = entry.localPath.startsWith("file://")
? entry.localPath
: `file://${entry.localPath}`;
const file = new File(fileUri);
if (file.exists) {
await file.delete();
}
} catch {
// Ignore deletion errors
}
delete index.tracks[entry.itemId];
}
index.totalPermanentSize = 0;
saveStorageIndex();
console.log(`[AudioStorage] Permanent downloads cleared`);
}
/**
* Get storage statistics
*/
export function getStorageStats(): {
cacheCount: number;
cacheSize: number;
permanentCount: number;
permanentSize: number;
} {
const index = getStorageIndex();
const entries = Object.values(index.tracks);
return {
cacheCount: entries.filter((t) => !t.permanent).length,
cacheSize: index.totalCacheSize,
permanentCount: entries.filter((t) => t.permanent).length,
permanentSize: index.totalPermanentSize,
};
}

View File

@@ -0,0 +1,42 @@
/**
* Audio Storage Types
*
* Shared foundation supporting both:
* - Look-ahead cache (auto-managed, ephemeral)
* - Future full music downloads (user-initiated, permanent)
*/
export interface StoredTrackInfo {
itemId: string;
localPath: string;
size: number;
storedAt: number;
permanent: boolean; // true = user download, false = cache
}
export interface AudioStorageIndex {
tracks: Record<string, StoredTrackInfo>;
totalCacheSize: number;
totalPermanentSize: number;
}
export interface DownloadOptions {
permanent: boolean;
container?: string; // File extension/format (e.g., "mp3", "flac", "m4a")
}
export interface DownloadCompleteEvent {
itemId: string;
localPath: string;
permanent: boolean;
}
export interface DownloadErrorEvent {
itemId: string;
error: string;
}
export interface DownloadProgressEvent {
itemId: string;
progress: number; // 0-1
}

View File

@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
let cachedDb: DownloadsDatabase | null = null;
let cacheVersion = 0;
// Performance optimization: Cache the flattened items array
let cachedItems: DownloadedItem[] | null = null;
let itemsCacheVersion = -1;
// Performance optimization: Index for O(1) item lookups by ID
let itemIndex: Map<string, DownloadedItem> | null = null;
let indexCacheVersion = -1;
/**
* Get the downloads database from storage
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
* saveDownloadsDatabase() runs and the derived caches stay consistent.
*/
export function getDownloadsDatabase(): DownloadsDatabase {
// Return cached database if available
if (cachedDb !== null) {
return cachedDb;
}
// Parse from storage and cache the result
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
if (file) {
return JSON.parse(file) as DownloadsDatabase;
cachedDb = JSON.parse(file) as DownloadsDatabase;
return cachedDb;
}
return { movies: {}, series: {}, other: {} };
const emptyDb = { movies: {}, series: {}, other: {} };
cachedDb = emptyDb;
return emptyDb;
}
/**
* Save the downloads database to storage
* PERFORMANCE: Updates cache and invalidates derived caches
*/
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
// Update the cache with the new database
cachedDb = db;
// Invalidate derived caches (items array and index)
cachedItems = null;
itemIndex = null;
cacheVersion++;
}
/**
* Get all downloaded items as a flat array
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
*/
export function getAllDownloadedItems(): DownloadedItem[] {
// Return cached items if available and up-to-date
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
return cachedItems;
}
// Build the items array from the database
const db = getDownloadsDatabase();
const items: DownloadedItem[] = [];
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
}
}
// Cache the result
cachedItems = items;
itemsCacheVersion = cacheVersion;
return items;
}
/**
* Get a downloaded item by its ID
* Build or refresh the item index for O(1) lookups
*/
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
const db = getDownloadsDatabase();
if (db.movies[id]) {
return db.movies[id];
function ensureItemIndex(): void {
if (itemIndex !== null && indexCacheVersion === cacheVersion) {
return; // Index is up-to-date
}
for (const series of Object.values(db.series)) {
for (const season of Object.values(series.seasons)) {
for (const episode of Object.values(season.episodes)) {
if (episode.item.Id === id) {
return episode;
}
}
// Build new index from all items
itemIndex = new Map<string, DownloadedItem>();
const items = getAllDownloadedItems();
for (const item of items) {
if (item.item.Id) {
itemIndex.set(item.item.Id, item);
}
}
if (db.other?.[id]) {
return db.other[id];
}
indexCacheVersion = cacheVersion;
}
return undefined;
/**
* Get a downloaded item by its ID
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
*/
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
ensureItemIndex();
return itemIndex!.get(id);
}
/**
@@ -221,4 +268,5 @@ export function updateDownloadedItem(
*/
export function clearAllDownloadedItems(): void {
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
// saveDownloadsDatabase already invalidates caches
}

View File

@@ -92,5 +92,24 @@ export function getDownloadedItemSize(id: string): number {
*/
export function calculateTotalDownloadedSize(): number {
const items = getAllDownloadedItems();
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
return items.reduce((sum, item) => {
// Trickplay bytes count too — getDownloadedItemSize models per-item size
// as video + trickplay, the total must match.
const trickplaySize = item.trickPlayData?.size ?? 0;
// Read the live file size on disk so the total reflects actual usage and
// self-heals items whose stored videoFileSize is 0 (old schema, or
// `fileInfo.size` was undefined at download time). Fall back to the stored
// value if the file can't be stat'd.
if (item.videoFilePath) {
try {
const file = new File(filePathToUri(item.videoFilePath));
if (file.exists) {
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
}
} catch (error) {
console.warn("Failed to stat downloaded file for size:", error);
}
}
return sum + (item.videoFileSize ?? 0) + trickplaySize;
}, 0);
}

View File

@@ -142,31 +142,12 @@ export function useDownloadEventHandlers({
} else {
// Transcoding - estimate from bitrate
const process = processes.find((p) => p.id === processId);
console.log(
`[DPL] Transcoding detected, looking for process ${processId}, found:`,
process ? "yes" : "no",
);
if (process) {
console.log(`[DPL] Process bitrate:`, {
key: process.maxBitrate.key,
value: process.maxBitrate.value,
runTimeTicks: process.item.RunTimeTicks,
});
if (process.maxBitrate.value && process.item.RunTimeTicks) {
const { estimateDownloadSize } = require("@/utils/download");
estimatedTotalBytes = estimateDownloadSize(
process.maxBitrate.value,
process.item.RunTimeTicks,
);
console.log(
`[DPL] Calculated estimatedTotalBytes:`,
estimatedTotalBytes,
);
} else {
console.log(
`[DPL] Cannot estimate size - bitrate.value or RunTimeTicks missing`,
);
}
if (process?.maxBitrate.value && process.item.RunTimeTicks) {
const { estimateDownloadSize } = require("@/utils/download");
estimatedTotalBytes = estimateDownloadSize(
process.maxBitrate.value,
process.item.RunTimeTicks,
);
}
}
@@ -235,6 +216,9 @@ export function useDownloadEventHandlers({
trickPlayData,
introSegments,
creditSegments,
audioStreamIndex,
subtitleStreamIndex,
isTranscoding,
} = process;
const videoFile = new File(filePathToUri(event.filePath));
const fileInfo = videoFile.info();
@@ -258,8 +242,9 @@ export function useDownloadEventHandlers({
introSegments,
creditSegments,
userData: {
audioStreamIndex: 0,
subtitleStreamIndex: 0,
audioStreamIndex: audioStreamIndex ?? 0,
subtitleStreamIndex: subtitleStreamIndex ?? -1,
isTranscoded: isTranscoding ?? false,
},
};

View File

@@ -62,6 +62,8 @@ export function useDownloadOperations({
item: BaseItemDto,
mediaSource: MediaSourceInfo,
maxBitrate: Bitrate,
audioStreamIndex?: number,
subtitleStreamIndex?: number,
) => {
if (!api || !item.Id || !authHeader) {
console.warn("startBackgroundDownload ~ Missing required params");
@@ -125,6 +127,8 @@ export function useDownloadOperations({
trickPlayData: additionalAssets.trickPlayData,
introSegments: additionalAssets.introSegments,
creditSegments: additionalAssets.creditSegments,
audioStreamIndex,
subtitleStreamIndex,
};
// Add to processes
@@ -305,7 +309,24 @@ export function useDownloadOperations({
);
const appSizeUsage = useCallback(async () => {
const totalSize = calculateTotalDownloadedSize();
let totalSize = calculateTotalDownloadedSize();
// Also count in-progress downloads (they write straight to their final
// path) so the growing file shows up as app usage instead of drifting
// into the generic device share until completion.
for (const process of processes) {
try {
const file = new File(
Paths.document,
`${generateFilename(process.item)}.mp4`,
);
if (file.exists) {
totalSize += file.size ?? 0;
}
} catch {
// File not created yet — ignore.
}
}
try {
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
@@ -326,7 +347,7 @@ export function useDownloadOperations({
appSize: totalSize,
};
}
}, []);
}, [processes]);
return {
startBackgroundDownload,

View File

@@ -21,6 +21,8 @@ interface UserData {
subtitleStreamIndex: number;
/** The last known audio stream index. */
audioStreamIndex: number;
/** Whether the downloaded file was transcoded (has only one audio track). */
isTranscoded: boolean;
}
/** Represents a segment of time in a media item, used for intro/credit skipping. */
@@ -142,4 +144,8 @@ export type JobStatus = {
introSegments?: MediaTimeSegment[];
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
creditSegments?: MediaTimeSegment[];
/** The audio stream index selected for this download */
audioStreamIndex?: number;
/** The subtitle stream index selected for this download */
subtitleStreamIndex?: number;
};

View File

@@ -5,10 +5,13 @@ import {
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { BackHandler, Platform } from "react-native";
interface ModalOptions {
enableDynamicSizing?: boolean;
snapPoints?: (string | number)[];
@@ -60,10 +63,6 @@ export const GlobalModalProvider: React.FC<GlobalModalProviderProps> = ({
(content: ReactNode, options?: ModalOptions) => {
setModalState({ content, options });
setIsVisible(true);
// Wait for state update and layout to complete before presenting
requestAnimationFrame(() => {
modalRef.current?.present();
});
},
[],
);
@@ -77,6 +76,25 @@ export const GlobalModalProvider: React.FC<GlobalModalProviderProps> = ({
});
}, []);
useEffect(() => {
if (Platform.OS !== "android") return;
const onBackPress = () => {
if (isVisible) {
hideModal();
return true;
}
return false;
};
const subscription = BackHandler.addEventListener(
"hardwareBackPress",
onBackPress,
);
return () => subscription.remove();
}, [isVisible, hideModal]);
const value = {
showModal,
hideModal,

View File

@@ -0,0 +1,221 @@
import type React from "react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
} from "react";
import { AppState, type AppStateStatus, Platform } from "react-native";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { InactivityTimeout, useSettings } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
const INACTIVITY_LAST_ACTIVITY_KEY = "INACTIVITY_LAST_ACTIVITY";
interface InactivityContextValue {
resetInactivityTimer: () => void;
pauseInactivityTimer: () => void;
resumeInactivityTimer: () => void;
}
const InactivityContext = createContext<InactivityContextValue | undefined>(
undefined,
);
/**
* TV-only provider that tracks user inactivity and auto-logs out
* when the configured timeout is exceeded.
*
* Features:
* - Tracks last activity timestamp (persisted to MMKV)
* - Resets timer on any focus change (via resetInactivityTimer)
* - Pauses timer during video playback (via pauseInactivityTimer/resumeInactivityTimer)
* - Handles app backgrounding: logs out immediately if timeout exceeded while away
* - No-op on mobile platforms
*/
export const InactivityProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const { settings } = useSettings();
const { logout } = useJellyfin();
const timerRef = useRef<NodeJS.Timeout | null>(null);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
const isPausedRef = useRef(false);
const timeoutMs = settings.inactivityTimeout ?? InactivityTimeout.Disabled;
const isEnabled = Platform.isTV && timeoutMs > 0;
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const updateLastActivity = useCallback(() => {
if (!isEnabled) return;
storage.set(INACTIVITY_LAST_ACTIVITY_KEY, Date.now());
}, [isEnabled]);
const getLastActivity = useCallback((): number => {
return storage.getNumber(INACTIVITY_LAST_ACTIVITY_KEY) ?? Date.now();
}, []);
const startTimer = useCallback(
(remainingMs?: number) => {
if (!isEnabled || isPausedRef.current) return;
clearTimer();
const delay = remainingMs ?? timeoutMs;
timerRef.current = setTimeout(() => {
logout();
storage.remove(INACTIVITY_LAST_ACTIVITY_KEY);
}, delay);
},
[isEnabled, timeoutMs, clearTimer, logout],
);
const resetInactivityTimer = useCallback(() => {
if (!isEnabled || isPausedRef.current) return;
updateLastActivity();
startTimer();
}, [isEnabled, updateLastActivity, startTimer]);
const pauseInactivityTimer = useCallback(() => {
if (!isEnabled) return;
isPausedRef.current = true;
clearTimer();
// Update last activity so when we resume, we start fresh
updateLastActivity();
}, [isEnabled, clearTimer, updateLastActivity]);
const resumeInactivityTimer = useCallback(() => {
if (!isEnabled) return;
isPausedRef.current = false;
updateLastActivity();
startTimer();
}, [isEnabled, updateLastActivity, startTimer]);
// Handle app state changes (background/foreground)
useEffect(() => {
if (!isEnabled) return;
const handleAppStateChange = (nextAppState: AppStateStatus) => {
const wasBackground =
appStateRef.current === "background" ||
appStateRef.current === "inactive";
const isNowActive = nextAppState === "active";
if (wasBackground && isNowActive) {
// App returned to foreground
// If paused (e.g., video playing), don't check timeout
if (isPausedRef.current) {
appStateRef.current = nextAppState;
return;
}
// Check if timeout exceeded
const lastActivity = getLastActivity();
const elapsed = Date.now() - lastActivity;
if (elapsed >= timeoutMs) {
// Timeout exceeded while backgrounded - logout immediately
logout();
storage.remove(INACTIVITY_LAST_ACTIVITY_KEY);
} else {
// Restart timer with remaining time
const remainingMs = timeoutMs - elapsed;
startTimer(remainingMs);
}
} else if (nextAppState === "background" || nextAppState === "inactive") {
// App going to background - clear timer (time continues via timestamp)
clearTimer();
}
appStateRef.current = nextAppState;
};
const subscription = AppState.addEventListener(
"change",
handleAppStateChange,
);
return () => {
subscription.remove();
};
}, [isEnabled, timeoutMs, getLastActivity, startTimer, clearTimer, logout]);
// Initialize timer when enabled or timeout changes
useEffect(() => {
if (!isEnabled) {
clearTimer();
return;
}
// Don't start timer if paused
if (isPausedRef.current) return;
// Check if we should logout based on last activity
const lastActivity = getLastActivity();
const elapsed = Date.now() - lastActivity;
if (elapsed >= timeoutMs) {
// Already timed out - logout
logout();
storage.remove(INACTIVITY_LAST_ACTIVITY_KEY);
} else {
// Start timer with remaining time
const remainingMs = timeoutMs - elapsed;
startTimer(remainingMs);
}
return () => {
clearTimer();
};
}, [isEnabled, timeoutMs, getLastActivity, startTimer, clearTimer, logout]);
// Reset activity on initial mount when enabled
useEffect(() => {
if (isEnabled && !isPausedRef.current) {
updateLastActivity();
startTimer();
}
}, []);
const contextValue: InactivityContextValue = {
resetInactivityTimer,
pauseInactivityTimer,
resumeInactivityTimer,
};
return (
<InactivityContext.Provider value={contextValue}>
{children}
</InactivityContext.Provider>
);
};
/**
* Hook to access the inactivity timer controls.
* Returns no-op functions if not within the provider (safe on mobile).
*/
export const useInactivity = (): InactivityContextValue => {
const context = useContext(InactivityContext);
// Return no-ops if not within provider (e.g., on mobile)
if (!context) {
return {
resetInactivityTimer: () => {},
pauseInactivityTimer: () => {},
resumeInactivityTimer: () => {},
};
}
return context;
};

View File

@@ -0,0 +1,55 @@
import React, {
createContext,
type ReactNode,
useCallback,
useContext,
useRef,
} from "react";
import { IntroSheet, type IntroSheetRef } from "@/components/IntroSheet";
interface IntroSheetContextType {
showIntro: () => void;
hideIntro: () => void;
}
const IntroSheetContext = createContext<IntroSheetContextType | undefined>(
undefined,
);
export const useIntroSheet = () => {
const context = useContext(IntroSheetContext);
if (!context) {
throw new Error("useIntroSheet must be used within IntroSheetProvider");
}
return context;
};
interface IntroSheetProviderProps {
children: ReactNode;
}
export const IntroSheetProvider: React.FC<IntroSheetProviderProps> = ({
children,
}) => {
const sheetRef = useRef<IntroSheetRef>(null);
const showIntro = useCallback(() => {
sheetRef.current?.present();
}, []);
const hideIntro = useCallback(() => {
sheetRef.current?.dismiss();
}, []);
const value = {
showIntro,
hideIntro,
};
return (
<IntroSheetContext.Provider value={value}>
{children}
<IntroSheet ref={sheetRef} />
</IntroSheetContext.Provider>
);
};

View File

@@ -2,9 +2,9 @@ import "@/augmentations";
import { type Api, Jellyfin } from "@jellyfin/sdk";
import type { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios, { AxiosError } from "axios";
import { router, useSegments } from "expo-router";
import { useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { atom, useAtom } from "jotai";
import type React from "react";
@@ -18,31 +18,110 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { getDeviceName } from "react-native-device-info";
import { AppState, Platform } from "react-native";
import { getDeviceNameSync } from "react-native-device-info";
import uuid from "react-native-uuid";
import useRouter from "@/hooks/useAppRouter";
import { useInterval } from "@/hooks/useInterval";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useSettings } from "@/utils/atoms/settings";
import { writeErrorLog, writeInfoLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import {
type AccountSecurityType,
addAccountToServer,
addServerToList,
deleteAccountCredential,
getAccountCredential,
hashPIN,
migrateToMultiAccount,
saveAccountCredential,
updateAccountToken,
} from "@/utils/secureCredentials";
import { store } from "@/utils/store";
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
import { APP_VERSION } from "@/utils/version";
interface Server {
address: string;
}
export const apiAtom = atom<Api | null>(null);
export const userAtom = atom<UserDto | null>(null);
const initialApi = (() => {
try {
const token = storage.getString("token") || null;
const serverUrl = storage.getString("serverUrl") || null;
if (serverUrl && token) {
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
const jellyfinInstance = new Jellyfin({
clientInfo: { name: "Streamyfin", version: APP_VERSION },
deviceInfo: {
name: deviceName,
id,
},
});
return jellyfinInstance.createApi(serverUrl, token);
}
} catch (e) {
console.error("Failed to initialize API synchronously:", e);
}
return null;
})();
const initialUser = (() => {
try {
// Only return a stored user if we also have a token. Otherwise the
// user atom would be populated while the api atom is null (e.g. after
// a logout that left stale user JSON in storage), which causes
// useProtectedRoute to keep us inside the (auth) group instead of
// redirecting to /login.
const token = storage.getString("token");
if (!token) return null;
const userStr = storage.getString("user");
if (userStr) {
return JSON.parse(userStr) as UserDto;
}
} catch (e) {
console.error("Failed to parse initial user synchronously:", e);
}
return null;
})();
export const apiAtom = atom<Api | null>(initialApi);
export const userAtom = atom<UserDto | null>(initialUser);
export const wsAtom = atom<WebSocket | null>(null);
export const cacheVersionAtom = atom<number>(0);
interface LoginOptions {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
}
interface JellyfinContextValue {
discoverServers: (url: string) => Promise<Server[]>;
setServer: (server: Server) => Promise<void>;
removeServer: () => void;
login: (username: string, password: string) => Promise<void>;
login: (
username: string,
password: string,
serverName?: string,
options?: LoginOptions,
) => Promise<void>;
logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>;
stopQuickConnectPolling: () => void;
loginWithSavedCredential: (
serverUrl: string,
userId: string,
) => Promise<void>;
loginWithPassword: (
serverUrl: string,
username: string,
password: string,
) => Promise<void>;
removeSavedCredential: (serverUrl: string, userId: string) => Promise<void>;
switchServerUrl: (newUrl: string) => void;
}
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
@@ -52,42 +131,46 @@ const JellyfinContext = createContext<JellyfinContextValue | undefined>(
export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
const [jellyfin] = useState<Jellyfin | undefined>(() => {
try {
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
return new Jellyfin({
clientInfo: { name: "Streamyfin", version: APP_VERSION },
deviceInfo: {
name: deviceName,
id,
},
});
} catch (e) {
console.error("Failed to initialize Jellyfin synchronously in state:", e);
return undefined;
}
});
const [deviceId] = useState<string | undefined>(() => {
try {
return getOrSetDeviceId();
} catch {
return undefined;
}
});
const { t } = useTranslation();
useEffect(() => {
(async () => {
const id = getOrSetDeviceId();
const deviceName = await getDeviceName();
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.48.0" },
deviceInfo: {
name: deviceName,
id,
},
}),
);
setDeviceId(id);
})();
}, []);
const [api, setApi] = useAtom(apiAtom);
const [user, setUser] = useAtom(userAtom);
const [isPolling, setIsPolling] = useState<boolean>(false);
const [secret, setSecret] = useState<string | null>(null);
const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings();
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
const queryClient = useQueryClient();
const headers = useMemo(() => {
if (!deviceId) return {};
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.48.0"`,
}, DeviceId="${deviceId}", Version="${APP_VERSION}"`,
};
}, [deviceId]);
@@ -113,8 +196,13 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
}, [api, deviceId, headers]);
const stopQuickConnectPolling = useCallback(() => {
setIsPolling(false);
setSecret(null);
}, []);
const pollQuickConnect = useCallback(async () => {
if (!api || !secret) return;
if (!api || !secret || !jellyfin) return;
try {
const response = await api.axiosInstance.get(
@@ -136,8 +224,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
);
const { AccessToken, User } = authResponse.data;
api.accessToken = AccessToken;
setUser(User);
setApi(jellyfin.createApi(api.basePath, AccessToken));
storage.set("token", AccessToken);
storage.set("user", JSON.stringify(User));
return true;
@@ -145,15 +233,20 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
return false;
} catch (error) {
if (error instanceof AxiosError && error.response?.status === 400) {
setIsPolling(false);
setSecret(null);
throw new Error("The code has expired. Please try again.");
if (error instanceof AxiosError) {
if (error.response?.status === 400 || error.response?.status === 404) {
setIsPolling(false);
setSecret(null);
if (error.response?.status === 400) {
throw new Error("The code has expired. Please try again.");
}
return false;
}
}
console.error("Error polling Quick Connect:", error);
throw error;
}
}, [api, secret, headers]);
}, [api, secret, headers, jellyfin]);
useEffect(() => {
(async () => {
@@ -166,7 +259,17 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}, [api]);
useInterval(pollQuickConnect, isPolling ? 1000 : null);
useInterval(refreshStreamyfinPluginSettings, 60 * 5 * 1000); // 5 min
// Refresh plugin settings when app comes to foreground
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (nextAppState === "active") {
refreshStreamyfinPluginSettings();
}
});
return () => subscription.remove();
}, []);
const discoverServers = async (url: string): Promise<Server[]> => {
const servers =
@@ -176,6 +279,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const setServerMutation = useMutation({
mutationFn: async (server: Server) => {
clearTVDiscoverySafely();
const apiInstance = jellyfin?.createApi(server.address);
if (!apiInstance?.basePath) throw new Error("Failed to connect");
@@ -183,18 +287,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setApi(apiInstance);
storage.set("serverUrl", server.address);
},
onSuccess: (_, server) => {
const previousServers = JSON.parse(
storage.getString("previousServers") || "[]",
);
const updatedServers = [
server,
...previousServers.filter((s: Server) => s.address !== server.address),
];
storage.set(
"previousServers",
JSON.stringify(updatedServers.slice(0, 5)),
);
onSuccess: async (_, server) => {
// Add server to the list (will update existing or add new)
addServerToList(server.address);
},
onError: (error) => {
console.error("Failed to set server:", error);
@@ -203,6 +298,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const removeServerMutation = useMutation({
mutationFn: async () => {
clearTVDiscoverySafely();
storage.remove("serverUrl");
setApi(null);
},
@@ -215,9 +311,13 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
mutationFn: async ({
username,
password,
serverName,
options,
}: {
username: string;
password: string;
serverName?: string;
options?: LoginOptions;
}) => {
if (!api || !jellyfin) throw new Error("API not initialized");
@@ -230,6 +330,28 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
storage.set("token", auth.data?.AccessToken);
// Save credentials to secure storage if requested
if (api.basePath && options?.saveAccount) {
const securityType = options.securityType || "none";
let pinHash: string | undefined;
if (securityType === "pin" && options.pinCode) {
pinHash = await hashPIN(options.pinCode);
}
await saveAccountCredential({
serverUrl: api.basePath,
serverName: serverName || "",
token: auth.data.AccessToken,
userId: auth.data.User.Id || "",
username,
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
});
}
const recentPluginSettings = await refreshStreamyfinPluginSettings();
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
const jellyseerrApi = new JellyseerrApi(
@@ -264,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
default:
throw new Error(
t(
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
),
);
}
@@ -279,6 +401,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const logoutMutation = useMutation({
mutationFn: async () => {
// Fire-and-forget: don't block logout on server cleanup
api
?.delete(`/Streamyfin/device/${deviceId}`)
.then((_r) => writeInfoLog("Deleted expo push token for device"))
@@ -287,16 +410,187 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
);
storage.remove("token");
storage.remove("user");
clearTVDiscoverySafely();
setUser(null);
setApi(null);
setPluginSettings(undefined);
await clearAllJellyseerData();
// Clear React Query cache to prevent data from previous account lingering
queryClient.clear();
storage.remove("REACT_QUERY_OFFLINE_CACHE");
// Note: We keep saved credentials for quick switching back
},
onError: (error) => {
console.error("Logout failed:", error);
},
});
const loginWithSavedCredentialMutation = useMutation({
mutationFn: async ({
serverUrl,
userId,
}: {
serverUrl: string;
userId: string;
}) => {
if (!jellyfin) throw new Error("Jellyfin not initialized");
const credential = await getAccountCredential(serverUrl, userId);
if (!credential) {
throw new Error("No saved credential found");
}
// Create API instance with saved token
const apiInstance = jellyfin.createApi(serverUrl, credential.token);
if (!apiInstance) {
throw new Error("Failed to create API instance");
}
// Validate token by fetching current user
try {
const response = await getUserApi(apiInstance).getCurrentUser();
// Clear React Query cache to prevent data from previous account lingering
queryClient.clear();
storage.remove("REACT_QUERY_OFFLINE_CACHE");
// Token is valid, update state
setApi(apiInstance);
setUser(response.data);
storage.set("serverUrl", serverUrl);
storage.set("token", credential.token);
storage.set("user", JSON.stringify(response.data));
// Update account info (in case user changed their avatar)
if (response.data.PrimaryImageTag !== credential.primaryImageTag) {
addAccountToServer(serverUrl, credential.serverName, {
userId: credential.userId,
username: credential.username,
securityType: credential.securityType,
savedAt: credential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
// Refresh plugin settings
await refreshStreamyfinPluginSettings();
} catch (error) {
// Check for axios error
if (axios.isAxiosError(error)) {
// Token is invalid/expired - remove it
if (
error.response?.status === 401 ||
error.response?.status === 403
) {
await deleteAccountCredential(serverUrl, userId);
throw new Error(t("server.session_expired"));
}
// Network error - server not reachable (no response means server didn't respond)
if (!error.response) {
throw new Error(t("home.server_unreachable"));
}
}
// Check for network error by message pattern (fallback detection)
if (
error instanceof Error &&
(error.message.toLowerCase().includes("network") ||
error.message.toLowerCase().includes("econnrefused") ||
error.message.toLowerCase().includes("timeout"))
) {
throw new Error(t("home.server_unreachable"));
}
throw error;
}
},
onError: (error) => {
console.error("Quick login failed:", error);
},
});
const loginWithPasswordMutation = useMutation({
mutationFn: async ({
serverUrl,
username,
password,
}: {
serverUrl: string;
username: string;
password: string;
}) => {
if (!jellyfin) throw new Error("Jellyfin not initialized");
// Create API instance for the server
const apiInstance = jellyfin.createApi(serverUrl);
if (!apiInstance) {
throw new Error("Failed to create API instance");
}
// Authenticate with password
const auth = await apiInstance.authenticateUserByName(username, password);
if (auth.data.AccessToken && auth.data.User) {
// Clear React Query cache to prevent data from previous account lingering
queryClient.clear();
storage.remove("REACT_QUERY_OFFLINE_CACHE");
setUser(auth.data.User);
storage.set("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(serverUrl, auth.data.AccessToken));
storage.set("serverUrl", serverUrl);
storage.set("token", auth.data.AccessToken);
// Update the saved credential with new token and image tag
await updateAccountToken(
serverUrl,
auth.data.User.Id || "",
auth.data.AccessToken,
auth.data.User.PrimaryImageTag ?? undefined,
);
// Refresh plugin settings
await refreshStreamyfinPluginSettings();
}
},
onError: (error) => {
console.error("Password login failed:", error);
throw error;
},
});
const removeSavedCredentialMutation = useMutation({
mutationFn: async ({
serverUrl,
userId,
}: {
serverUrl: string;
userId: string;
}) => {
await deleteAccountCredential(serverUrl, userId);
},
onError: (error) => {
console.error("Failed to remove saved credential:", error);
},
});
const switchServerUrl = useCallback(
(newUrl: string) => {
if (!jellyfin || !api?.accessToken) return;
clearTVDiscoverySafely();
const newApi = jellyfin.createApi(newUrl, api.accessToken);
setApi(newApi);
// Note: We don't update storage.set("serverUrl") here
// because we want to keep the original remote URL as the "primary" URL
},
[jellyfin, api?.accessToken],
);
const [loaded, setLoaded] = useState(false);
const [initialLoaded, setInitialLoaded] = useState(false);
@@ -311,6 +605,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (!jellyfin) return;
try {
// Run migration to multi-account format (once)
await migrateToMultiAccount();
const token = getTokenFromStorage();
const serverUrl = getServerUrlFromStorage();
const storedUser = getUserFromStorage();
@@ -323,12 +620,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser);
}
const response = await getUserApi(apiInstance).getCurrentUser();
setUser(response.data);
// Dismiss splash screen with cached data immediately,
// fetch fresh user data in the background
setInitialLoaded(true);
try {
const response = await getUserApi(apiInstance).getCurrentUser();
setUser(response.data);
// Migrate current session to secure storage if not already saved
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
storedUser.Id,
);
if (!existingCredential) {
await saveAccountCredential({
serverUrl,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
}
} catch (e) {
// Background fetch failed — app already rendered with cached data
console.warn("Background user fetch failed, using cached data:", e);
}
} else {
setInitialLoaded(true);
}
} catch (e) {
console.error(e);
} finally {
setInitialLoaded(true);
}
};
@@ -340,10 +679,18 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
discoverServers,
setServer: (server) => setServerMutation.mutateAsync(server),
removeServer: () => removeServerMutation.mutateAsync(),
login: (username, password) =>
loginMutation.mutateAsync({ username, password }),
login: (username, password, serverName, options) =>
loginMutation.mutateAsync({ username, password, serverName, options }),
logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect,
stopQuickConnectPolling,
loginWithSavedCredential: (serverUrl, userId) =>
loginWithSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
loginWithPassword: (serverUrl, username, password) =>
loginWithPasswordMutation.mutateAsync({ serverUrl, username, password }),
removeSavedCredential: (serverUrl, userId) =>
removeSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
switchServerUrl,
};
useEffect(() => {
@@ -370,17 +717,17 @@ export const useJellyfin = (): JellyfinContextValue => {
function useProtectedRoute(user: UserDto | null, loaded = false) {
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (loaded === false) return;
const inAuthGroup = segments.length > 1 && segments[0] === "(auth)";
const isTopShelfLaunchRoute = segments[0] === "topshelf";
if (!user?.Id && inAuthGroup) {
console.log("Redirected to login");
router.replace("/login");
} else if (user?.Id && !inAuthGroup) {
console.log("Redirected to home");
} else if (user?.Id && !inAuthGroup && !isTopShelfLaunchRoute) {
router.replace("/(auth)/(tabs)/(home)/");
}
}, [user, segments, loaded]);

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import NetInfo from "@react-native-community/netinfo";
import { useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import {
createContext,
@@ -6,6 +7,7 @@ import {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { apiAtom } from "@/providers/JellyfinProvider";
@@ -24,7 +26,8 @@ const NetworkStatusContext = createContext<NetworkStatusContextType | null>(
async function checkApiReachable(basePath?: string): Promise<boolean> {
if (!basePath) return false;
try {
const response = await fetch(basePath, { method: "HEAD" });
const url = basePath.endsWith("/") ? basePath : `${basePath}/`;
const response = await fetch(url, { method: "HEAD" });
return response.ok;
} catch {
return false;
@@ -32,10 +35,12 @@ async function checkApiReachable(basePath?: string): Promise<boolean> {
}
export function NetworkStatusProvider({ children }: { children: ReactNode }) {
const [isConnected, setIsConnected] = useState(false);
const [serverConnected, setServerConnected] = useState<boolean | null>(null);
const [isConnected, setIsConnected] = useState(true);
const [serverConnected, setServerConnected] = useState<boolean | null>(true);
const [loading, setLoading] = useState(false);
const [api] = useAtom(apiAtom);
const queryClient = useQueryClient();
const wasServerConnected = useRef<boolean | null>(null);
const validateConnection = useCallback(async () => {
if (!api?.basePath) return false;
@@ -72,6 +77,14 @@ export function NetworkStatusProvider({ children }: { children: ReactNode }) {
return () => unsubscribe();
}, [validateConnection]);
// Refetch active queries when server becomes reachable
useEffect(() => {
if (serverConnected && wasServerConnected.current === false) {
queryClient.refetchQueries({ type: "active" });
}
wasServerConnected.current = serverConnected;
}, [serverConnected, queryClient]);
return (
<NetworkStatusContext.Provider
value={{ isConnected, serverConnected, loading, retryCheck }}

View File

@@ -0,0 +1,37 @@
import { createContext, type ReactNode, useContext } from "react";
const UNSET = Symbol("OfflineModeNotProvided");
const OfflineModeContext = createContext<boolean | typeof UNSET>(UNSET);
interface OfflineModeProviderProps {
isOffline: boolean;
children: ReactNode;
}
/**
* Provides offline mode state to all child components.
* Wrap pages that support offline mode with this provider.
*/
export function OfflineModeProvider({
isOffline,
children,
}: OfflineModeProviderProps) {
return (
<OfflineModeContext.Provider value={isOffline}>
{children}
</OfflineModeContext.Provider>
);
}
/**
* Returns whether the current view is in offline mode.
* Must be used within an OfflineModeProvider (set at page level).
*/
export function useOfflineMode(): boolean {
const context = useContext(OfflineModeContext);
if (context === UNSET) {
return false;
}
return context;
}

View File

@@ -5,10 +5,11 @@ import type {
import { useAtomValue } from "jotai";
import type React from "react";
import { createContext, useCallback, useContext, useState } from "react";
import { Platform } from "react-native";
import type { Bitrate } from "@/components/BitrateSelector";
import { settingsAtom } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { generateDeviceProfile } from "../utils/profiles/native";
import { apiAtom, userAtom } from "./JellyfinProvider";
export type PlaybackType = {
@@ -77,7 +78,12 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
}
try {
const native = generateDeviceProfile();
// Generate device profile for MPV player
const native = generateDeviceProfile({
platform: Platform.OS as "ios" | "android",
player: "mpv",
audioMode: settings.audioTranscodeMode,
});
const data = await getStreamUrl({
api,
deviceProfile: native,

View File

@@ -0,0 +1,124 @@
import { useAtomValue } from "jotai";
import type React from "react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useWifiSSID } from "@/hooks/useWifiSSID";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { storage } from "@/utils/mmkv";
import { getServerLocalConfig } from "@/utils/secureCredentials";
interface ServerUrlContextValue {
effectiveServerUrl: string | null;
isUsingLocalUrl: boolean;
currentSSID: string | null;
refreshUrlState: () => void;
}
const ServerUrlContext = createContext<ServerUrlContextValue | null>(null);
const DEBOUNCE_MS = 500;
interface Props {
children: ReactNode;
}
export function ServerUrlProvider({ children }: Props): React.ReactElement {
const api = useAtomValue(apiAtom);
const { switchServerUrl } = useJellyfin();
const { ssid, permissionStatus } = useWifiSSID();
const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false);
const [effectiveServerUrl, setEffectiveServerUrl] = useState<string | null>(
null,
);
const remoteUrlRef = useRef<string | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSSIDRef = useRef<string | null>(null);
// Sync remoteUrl from storage when api changes
useEffect(() => {
const storedUrl = storage.getString("serverUrl");
if (storedUrl) {
remoteUrlRef.current = storedUrl;
}
if (api?.basePath && !effectiveServerUrl) {
setEffectiveServerUrl(api.basePath);
}
}, [api?.basePath, effectiveServerUrl]);
// Function to evaluate and switch URL based on current config and SSID
const evaluateAndSwitchUrl = useCallback(() => {
const remoteUrl = remoteUrlRef.current;
if (!remoteUrl || !switchServerUrl) return;
const config = getServerLocalConfig(remoteUrl);
const shouldUseLocal = Boolean(
config?.enabled &&
config.localUrl &&
ssid !== null &&
config.homeWifiSSIDs.includes(ssid),
);
const targetUrl = shouldUseLocal ? config!.localUrl : remoteUrl;
switchServerUrl(targetUrl);
setIsUsingLocalUrl(shouldUseLocal);
setEffectiveServerUrl(targetUrl);
}, [ssid, switchServerUrl]);
// Manual refresh function for when config changes
const refreshUrlState = useCallback(() => {
evaluateAndSwitchUrl();
}, [evaluateAndSwitchUrl]);
// Debounced SSID change handler
useEffect(() => {
if (permissionStatus !== "granted") return;
if (ssid === lastSSIDRef.current) return;
lastSSIDRef.current = ssid;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
evaluateAndSwitchUrl();
}, DEBOUNCE_MS);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [ssid, permissionStatus, evaluateAndSwitchUrl]);
return (
<ServerUrlContext.Provider
value={{
effectiveServerUrl,
isUsingLocalUrl,
currentSSID: ssid,
refreshUrlState,
}}
>
{children}
</ServerUrlContext.Provider>
);
}
export function useServerUrl(): ServerUrlContextValue {
const context = useContext(ServerUrlContext);
if (!context) {
throw new Error("useServerUrl must be used within ServerUrlProvider");
}
return context;
}

View File

@@ -1,5 +1,5 @@
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { useRouter } from "expo-router";
import { router } from "expo-router";
import { useAtomValue } from "jotai";
import {
createContext,
@@ -8,10 +8,39 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
// Query keys that depend on the set of library items and should be refreshed
// when the server reports that the library changed (items added/removed/updated).
const LIBRARY_CHANGE_QUERY_KEYS = [
["home"],
["library-items"],
["nextUp-all"],
["nextUp"],
["resumeItems"],
["seasons"],
["episodes"],
] as const;
// Query keys that depend on per-user playback state (resume position, played
// status, favorites) and should be refreshed when the server reports a
// `UserDataChanged`. Scoped to the progression-based sections so finishing an
// episode does not pointlessly refetch "recently added" or suggestions.
const USER_DATA_CHANGE_QUERY_KEYS = [
["home", "continueAndNextUp"],
["home", "resumeItems"],
["home", "nextUp-all"],
["home", "heroItems"],
["resumeItems"],
["nextUp-all"],
["nextUp"],
] as const;
interface WebSocketMessage {
MessageType: string;
@@ -23,10 +52,30 @@ interface WebSocketProviderProps {
children: ReactNode;
}
/**
* Handler invoked for every message of a given `MessageType`. Receives the
* message `Data` payload and the full message.
*/
type WebSocketMessageHandler = (data: any, message: WebSocketMessage) => void;
interface WebSocketContextType {
ws: WebSocket | null;
isConnected: boolean;
/**
* @deprecated Prefer `subscribe`. `lastMessage` only keeps the most recent
* message, so bursts arriving in the same tick are coalesced and lost. Kept
* for `useWebsockets` (GeneralCommand handling) until it is migrated.
*/
lastMessage: WebSocketMessage | null;
/**
* Subscribe to a given message type. The handler is called synchronously for
* every matching message (no coalescing, unlike `lastMessage`). Returns an
* unsubscribe function to call on cleanup.
*/
subscribe: (
messageType: string,
handler: WebSocketMessageHandler,
) => () => void;
sendMessage: (message: any) => void;
clearLastMessage: () => void;
}
@@ -35,16 +84,89 @@ const WebSocketContext = createContext<WebSocketContextType | null>(null);
export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const api = useAtomValue(apiAtom);
const { isConnected: isNetworkConnected } = useNetworkStatus();
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const router = useRouter();
const queryClient = useNetworkAwareQueryClient();
const deviceId = useMemo(() => {
return getOrSetDeviceId();
}, []);
const reconnectAttemptsRef = useRef(0);
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const userDataChangeDebounceRef = useRef<ReturnType<
typeof setTimeout
> | null>(null);
// Handle for the onerror backoff timer. Tracked so a reconnect triggered by
// another path (foreground, network reconnect, effect re-run) can cancel a
// pending one — an untracked timer would later open a second socket.
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
// Pub/sub registry: messageType -> set of handlers. Stored in a ref so
// subscribing/dispatching never triggers a re-render.
const listenersRef = useRef<Map<string, Set<WebSocketMessageHandler>>>(
new Map(),
);
const subscribe = useCallback(
(messageType: string, handler: WebSocketMessageHandler) => {
const listeners = listenersRef.current;
let handlers = listeners.get(messageType);
if (!handlers) {
handlers = new Set();
listeners.set(messageType, handlers);
}
handlers.add(handler);
return () => {
handlers?.delete(handler);
// Only drop the map entry if it still points at THIS set. After an
// unsubscribe + re-subscribe for the same type, a stale second call to
// this cleanup would otherwise delete the new subscribers' set and
// silently stop delivering their messages.
if (
handlers &&
handlers.size === 0 &&
listeners.get(messageType) === handlers
) {
listeners.delete(messageType);
}
};
},
[],
);
const dispatchMessage = useCallback((message: WebSocketMessage) => {
const handlers = listenersRef.current.get(message.MessageType);
if (!handlers || handlers.size === 0) return;
// Copy to tolerate handlers that unsubscribe during dispatch.
for (const handler of [...handlers]) {
// Isolate each handler so one throwing subscriber can't abort the rest
// (and isn't misreported as a parse failure by the outer onmessage catch).
try {
handler(message.Data, message);
} catch (error) {
console.error(
`Error handling WebSocket message type "${message.MessageType}":`,
error,
);
}
}
}, []);
const connectWebSocket = useCallback(() => {
if (!deviceId || !api?.accessToken) {
// Cancel any reconnect queued by a previous onerror before opening a new
// socket, so we never end up with two live sockets — each would double the
// message fan-out and double-invalidate queries.
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
return;
}
@@ -58,9 +180,16 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const newWebSocket = new WebSocket(url);
let keepAliveInterval: ReturnType<typeof setInterval> | null = null;
const maxReconnectAttempts = 5;
const reconnectDelay = 10000;
newWebSocket.onopen = () => {
console.log("WebSocket connection opened");
setIsConnected(true);
reconnectAttemptsRef.current = 0;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
@@ -68,22 +197,21 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}, 30000);
};
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectDelay = 10000;
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
newWebSocket.onerror = () => {
// Don't log errors - this is expected when offline or server unreachable
setIsConnected(false);
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
setTimeout(() => {
console.log(`WebSocket reconnect attempt ${reconnectAttempts}`);
// Replace any still-pending reconnect so only one is ever queued; the
// previously untracked handle could leak and open a second socket.
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
reconnectTimeoutRef.current = setTimeout(() => {
reconnectTimeoutRef.current = null;
connectWebSocket();
}, reconnectDelay);
} else {
console.warn("Max WebSocket reconnect attempts reached.");
}
};
@@ -96,8 +224,10 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
newWebSocket.onmessage = (e) => {
try {
const message = JSON.parse(e.data);
console.log("[WS] Received message:", message);
setLastMessage(message); // Store the last message in context
// Legacy single-slot state, still consumed by useWebsockets.
setLastMessage(message);
// Pub/sub: deliver to every subscriber without coalescing.
dispatchMessage(message);
} catch (error) {
console.error("Error parsing WebSocket message:", error);
}
@@ -108,43 +238,117 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
newWebSocket.close();
};
}, [api, deviceId]);
}, [api, deviceId, isNetworkConnected, dispatchMessage]);
useEffect(() => {
if (!lastMessage) {
return;
}
if (lastMessage.MessageType === "Play") {
handlePlayCommand(lastMessage.Data);
}
}, [lastMessage, router]);
const handlePlayCommand = useCallback(
const handleLibraryChanged = useCallback(
(data: any) => {
if (!data || !data.ItemIds || !data.ItemIds.length) {
console.warn("[WS] Received Play command with no items");
// Jellyfin sends LibraryChanged when a scan adds/updates/removes items.
// Only refresh when something actually changed in the item set.
const hasChanges =
(data?.ItemsAdded?.length ?? 0) > 0 ||
(data?.ItemsRemoved?.length ?? 0) > 0 ||
(data?.ItemsUpdated?.length ?? 0) > 0 ||
(data?.FoldersAddedTo?.length ?? 0) > 0 ||
(data?.FoldersRemovedFrom?.length ?? 0) > 0;
if (!hasChanges) {
return;
}
const itemId = data.ItemIds[0];
console.log(`[WS] Handling Play command for item: ${itemId}`);
router.push({
pathname: "/(auth)/player/direct-player",
params: {
itemId: itemId,
playCommand: data.PlayCommand || "PlayNow",
audioIndex: data.AudioStreamIndex?.toString(),
subtitleIndex: data.SubtitleStreamIndex?.toString(),
mediaSourceId: data.MediaSourceId || "",
bitrateValue: "",
offline: "false",
},
});
// A single scan can emit several LibraryChanged messages in quick
// succession, so debounce the invalidation to refetch only once.
if (libraryChangeDebounceRef.current) {
clearTimeout(libraryChangeDebounceRef.current);
}
libraryChangeDebounceRef.current = setTimeout(() => {
for (const queryKey of LIBRARY_CHANGE_QUERY_KEYS) {
queryClient.invalidateQueries({ queryKey: [...queryKey] });
}
}, 1000);
},
[router],
[queryClient],
);
const handleUserDataChanged = useCallback(
(data: any) => {
// Jellyfin sends UserDataChanged when playback position, played status
// or favorites change (e.g. finishing an episode). Only the
// progression-based home sections care about it.
if (!((data?.UserDataList?.length ?? 0) > 0)) {
return;
}
// Finishing an item can emit several UserDataChanged messages, so
// debounce to invalidate the affected sections only once.
if (userDataChangeDebounceRef.current) {
clearTimeout(userDataChangeDebounceRef.current);
}
userDataChangeDebounceRef.current = setTimeout(() => {
for (const queryKey of USER_DATA_CHANGE_QUERY_KEYS) {
queryClient.invalidateQueries({ queryKey: [...queryKey] });
}
}, 800);
},
[queryClient],
);
// Refresh library-dependent queries when the server reports a change.
useEffect(
() => subscribe("LibraryChanged", handleLibraryChanged),
[subscribe, handleLibraryChanged],
);
// Refresh "Continue Watching" / "Next Up" when playback state changes.
useEffect(
() => subscribe("UserDataChanged", handleUserDataChanged),
[subscribe, handleUserDataChanged],
);
useEffect(() => {
return () => {
if (libraryChangeDebounceRef.current) {
clearTimeout(libraryChangeDebounceRef.current);
}
if (userDataChangeDebounceRef.current) {
clearTimeout(userDataChangeDebounceRef.current);
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, []);
const handlePlayCommand = useCallback((data: any) => {
if (!data?.ItemIds?.length) {
return;
}
const itemId = data.ItemIds[0];
router.push({
pathname: "/(auth)/player/direct-player",
params: {
itemId: itemId,
playCommand: data.PlayCommand || "PlayNow",
audioIndex: data.AudioStreamIndex?.toString(),
subtitleIndex: data.SubtitleStreamIndex?.toString(),
mediaSourceId: data.MediaSourceId || "",
bitrateValue: "",
offline: "false",
},
});
}, []);
// Server-initiated "Play me this item" remote command.
useEffect(
() => subscribe("Play", handlePlayCommand),
[subscribe, handlePlayCommand],
);
useEffect(() => {
@@ -153,26 +357,31 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}, [connectWebSocket]);
useEffect(() => {
if (!deviceId || !api || !api?.accessToken) {
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
return;
}
const init = async () => {
await getSessionApi(api).postFullCapabilities({
clientCapabilitiesDto: {
AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679",
IconUrl:
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
PlayableMediaTypes: ["Audio", "Video"],
SupportedCommands: ["Play"],
SupportsMediaControl: true,
SupportsPersistentIdentifier: true,
},
});
try {
await getSessionApi(api).postFullCapabilities({
clientCapabilitiesDto: {
AppStoreUrl:
"https://apps.apple.com/us/app/streamyfin/id6593660679",
IconUrl:
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
PlayableMediaTypes: ["Audio", "Video"],
SupportedCommands: ["Play"],
SupportsMediaControl: true,
SupportsPersistentIdentifier: true,
},
});
} catch {
// Silently fail - expected when offline or server unreachable
}
};
init();
}, [api, deviceId]);
}, [api, deviceId, isNetworkConnected]);
useEffect(() => {
const handleAppStateChange = (state: AppStateStatus) => {
@@ -199,9 +408,8 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
(message: any) => {
if (ws && isConnected) {
ws.send(JSON.stringify(message));
} else {
console.warn("Cannot send message: WebSocket is not connected");
}
// Silently fail when not connected - expected when offline
},
[ws, isConnected],
);
@@ -210,7 +418,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}, []);
return (
<WebSocketContext.Provider
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
value={{
ws,
isConnected,
lastMessage,
subscribe,
sendMessage,
clearLastMessage,
}}
>
{children}
</WebSocketContext.Provider>