mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 18:24:18 +01:00
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:
670
providers/AudioStorage/index.ts
Normal file
670
providers/AudioStorage/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
42
providers/AudioStorage/types.ts
Normal file
42
providers/AudioStorage/types.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
221
providers/InactivityProvider.tsx
Normal file
221
providers/InactivityProvider.tsx
Normal 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;
|
||||
};
|
||||
55
providers/IntroSheetProvider.tsx
Normal file
55
providers/IntroSheetProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
|
||||
1696
providers/MusicPlayerProvider.tsx
Normal file
1696
providers/MusicPlayerProvider.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 }}
|
||||
|
||||
37
providers/OfflineModeProvider.tsx
Normal file
37
providers/OfflineModeProvider.tsx
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
124
providers/ServerUrlProvider.tsx
Normal file
124
providers/ServerUrlProvider.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user