diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 293ddc49..8c813bb4 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -141,7 +141,9 @@ export default function page() { const offline = offlineStr === "true"; const playbackManager = usePlaybackManager({ isOffline: offline }); - const audioIndex = audioIndexStr + // Audio index: use URL param if provided, otherwise use stored index for offline playback + // This is computed after downloadedItem is available, see audioIndexResolved below + const audioIndexFromUrl = audioIndexStr ? Number.parseInt(audioIndexStr, 10) : undefined; const subtitleIndex = subtitleIndexStr @@ -160,6 +162,17 @@ export default function page() { isError: false, }); + // Resolve audio index: use URL param if provided, otherwise use stored index for offline playback + const audioIndex = useMemo(() => { + if (audioIndexFromUrl !== undefined) { + return audioIndexFromUrl; + } + if (offline && downloadedItem?.userData?.audioStreamIndex !== undefined) { + return downloadedItem.userData.audioStreamIndex; + } + return undefined; + }, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]); + // Get the playback speed for this item based on settings const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed( item, @@ -1119,6 +1132,8 @@ export default function page() { isVideoLoaded={isVideoLoaded} tracksReady={tracksReady} useVlcPlayer={useVlcPlayer} + offline={offline} + downloadedItem={downloadedItem} > = ({ ); continue; } + // Get the audio/subtitle indices that were used for this download + const downloadAudioIndex = + itemsNotDownloaded.length > 1 + ? getDefaultPlaySettings(item, settings!).audioIndex + : selectedOptions?.audioIndex; + const downloadSubtitleIndex = + itemsNotDownloaded.length > 1 + ? getDefaultPlaySettings(item, settings!).subtitleIndex + : selectedOptions?.subtitleIndex; + await startBackgroundDownload( url, item, mediaSource, selectedOptions?.bitrate || defaultBitrate, + downloadAudioIndex, + downloadSubtitleIndex, ); } }, diff --git a/components/video-player/controls/contexts/PlayerContext.tsx b/components/video-player/controls/contexts/PlayerContext.tsx index 653e9658..32acc30f 100644 --- a/components/video-player/controls/contexts/PlayerContext.tsx +++ b/components/video-player/controls/contexts/PlayerContext.tsx @@ -10,6 +10,7 @@ import React, { useMemo, } from "react"; import type { SfPlayerViewRef, VlcPlayerViewRef } from "@/modules"; +import type { DownloadedItem } from "@/providers/Downloads/types"; // Union type for both player refs type PlayerRef = SfPlayerViewRef | VlcPlayerViewRef; @@ -21,6 +22,8 @@ interface PlayerContextProps { isVideoLoaded: boolean; tracksReady: boolean; useVlcPlayer: boolean; + offline: boolean; + downloadedItem: DownloadedItem | null; } const PlayerContext = createContext(undefined); @@ -33,6 +36,8 @@ interface PlayerProviderProps { isVideoLoaded: boolean; tracksReady: boolean; useVlcPlayer: boolean; + offline?: boolean; + downloadedItem?: DownloadedItem | null; } export const PlayerProvider: React.FC = ({ @@ -43,6 +48,8 @@ export const PlayerProvider: React.FC = ({ isVideoLoaded, tracksReady, useVlcPlayer, + offline = false, + downloadedItem = null, }) => { const value = useMemo( () => ({ @@ -52,8 +59,19 @@ export const PlayerProvider: React.FC = ({ isVideoLoaded, tracksReady, useVlcPlayer, + offline, + downloadedItem, }), - [playerRef, item, mediaSource, isVideoLoaded, tracksReady, useVlcPlayer], + [ + playerRef, + item, + mediaSource, + isVideoLoaded, + tracksReady, + useVlcPlayer, + offline, + downloadedItem, + ], ); return ( diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx index 456d48b9..1f9fb2d4 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -75,7 +75,8 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({ const [subtitleTracks, setSubtitleTracks] = useState(null); const [audioTracks, setAudioTracks] = useState(null); - const { tracksReady, mediaSource, useVlcPlayer } = usePlayerContext(); + const { tracksReady, mediaSource, useVlcPlayer, offline, downloadedItem } = + usePlayerContext(); const playerControls = usePlayerControls(); const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } = @@ -131,6 +132,86 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({ if (!tracksReady) return; const fetchTracks = async () => { + // Check if this is offline transcoded content + // For transcoded offline content, only ONE audio track exists in the file + const isOfflineTranscoded = + offline && downloadedItem?.userData?.isTranscoded === true; + + if (isOfflineTranscoded) { + // Build single audio track entry - only the downloaded track exists + const downloadedAudioIndex = downloadedItem.userData.audioStreamIndex; + const downloadedTrack = allAudio.find( + (a) => a.Index === downloadedAudioIndex, + ); + + if (downloadedTrack) { + const audio: Track[] = [ + { + name: downloadedTrack.DisplayTitle || "Audio", + index: downloadedTrack.Index ?? 0, + mpvIndex: useVlcPlayer ? 0 : 1, // Only track in file + setTrack: () => { + // Track is already selected (only one available) + router.setParams({ audioIndex: String(downloadedTrack.Index) }); + }, + }, + ]; + setAudioTracks(audio); + } else { + // Fallback: show no audio tracks if the stored track wasn't found + setAudioTracks([]); + } + + // For subtitles in transcoded offline content: + // - Text-based subs may still be embedded + // - Image-based subs were burned in during transcoding + const downloadedSubtitleIndex = + downloadedItem.userData.subtitleStreamIndex; + const subs: Track[] = []; + + // Add "Disable" option + subs.push({ + name: "Disable", + index: -1, + mpvIndex: -1, + setTrack: () => { + playerControls.setSubtitleTrack(-1); + router.setParams({ subtitleIndex: "-1" }); + }, + }); + + // For text-based subs, they should still be available in the file + let subIdx = 1; + for (const sub of allSubs) { + if (sub.IsTextSubtitleStream) { + subs.push({ + name: sub.DisplayTitle || "Unknown", + index: sub.Index ?? -1, + mpvIndex: subIdx, + setTrack: () => { + playerControls.setSubtitleTrack(subIdx); + router.setParams({ subtitleIndex: String(sub.Index) }); + }, + }); + subIdx++; + } else if (sub.Index === downloadedSubtitleIndex) { + // This image-based sub was burned in - show it but indicate it's active + subs.push({ + name: `${sub.DisplayTitle || "Unknown"} (burned in)`, + index: sub.Index ?? -1, + mpvIndex: -1, // Can't be changed + setTrack: () => { + // Already burned in, just update params + router.setParams({ subtitleIndex: String(sub.Index) }); + }, + }); + } + } + + setSubtitleTracks(subs.sort((a, b) => a.index - b.index)); + return; + } + // For VLC player, use simpler track handling with server indices if (useVlcPlayer) { // Get VLC track info (VLC returns TrackInfo[] with 'index' property) @@ -347,7 +428,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({ }; fetchTracks(); - }, [tracksReady, mediaSource, useVlcPlayer]); + }, [tracksReady, mediaSource, useVlcPlayer, offline, downloadedItem]); return ( diff --git a/providers/Downloads/hooks/useDownloadEventHandlers.ts b/providers/Downloads/hooks/useDownloadEventHandlers.ts index a3a27b8a..ebf8b116 100644 --- a/providers/Downloads/hooks/useDownloadEventHandlers.ts +++ b/providers/Downloads/hooks/useDownloadEventHandlers.ts @@ -235,6 +235,9 @@ export function useDownloadEventHandlers({ trickPlayData, introSegments, creditSegments, + audioStreamIndex, + subtitleStreamIndex, + isTranscoding, } = process; const videoFile = new File(filePathToUri(event.filePath)); const fileInfo = videoFile.info(); @@ -258,8 +261,9 @@ export function useDownloadEventHandlers({ introSegments, creditSegments, userData: { - audioStreamIndex: 0, - subtitleStreamIndex: 0, + audioStreamIndex: audioStreamIndex ?? 0, + subtitleStreamIndex: subtitleStreamIndex ?? -1, + isTranscoded: isTranscoding ?? false, }, }; diff --git a/providers/Downloads/hooks/useDownloadOperations.ts b/providers/Downloads/hooks/useDownloadOperations.ts index f1e4c4ed..90e8036c 100644 --- a/providers/Downloads/hooks/useDownloadOperations.ts +++ b/providers/Downloads/hooks/useDownloadOperations.ts @@ -58,6 +58,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"); @@ -114,6 +116,8 @@ export function useDownloadOperations({ trickPlayData: additionalAssets.trickPlayData, introSegments: additionalAssets.introSegments, creditSegments: additionalAssets.creditSegments, + audioStreamIndex, + subtitleStreamIndex, }; // Add to processes diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts index de9d074e..976a6e23 100644 --- a/providers/Downloads/types.ts +++ b/providers/Downloads/types.ts @@ -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; };