fix(downloads): preserve audio track selection for offline playback

This commit is contained in:
Fredrik Burmester
2026-01-08 21:32:22 +01:00
parent 51ecde1565
commit d1387ec725
7 changed files with 146 additions and 6 deletions

View File

@@ -141,7 +141,9 @@ export default function page() {
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager({ isOffline: offline }); 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) ? Number.parseInt(audioIndexStr, 10)
: undefined; : undefined;
const subtitleIndex = subtitleIndexStr const subtitleIndex = subtitleIndexStr
@@ -160,6 +162,17 @@ export default function page() {
isError: false, 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 // Get the playback speed for this item based on settings
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed( const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
item, item,
@@ -1119,6 +1132,8 @@ export default function page() {
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
tracksReady={tracksReady} tracksReady={tracksReady}
useVlcPlayer={useVlcPlayer} useVlcPlayer={useVlcPlayer}
offline={offline}
downloadedItem={downloadedItem}
> >
<VideoProvider> <VideoProvider>
<View <View

View File

@@ -236,11 +236,23 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
continue; 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( await startBackgroundDownload(
url, url,
item, item,
mediaSource, mediaSource,
selectedOptions?.bitrate || defaultBitrate, selectedOptions?.bitrate || defaultBitrate,
downloadAudioIndex,
downloadSubtitleIndex,
); );
} }
}, },

View File

@@ -10,6 +10,7 @@ import React, {
useMemo, useMemo,
} from "react"; } from "react";
import type { SfPlayerViewRef, VlcPlayerViewRef } from "@/modules"; import type { SfPlayerViewRef, VlcPlayerViewRef } from "@/modules";
import type { DownloadedItem } from "@/providers/Downloads/types";
// Union type for both player refs // Union type for both player refs
type PlayerRef = SfPlayerViewRef | VlcPlayerViewRef; type PlayerRef = SfPlayerViewRef | VlcPlayerViewRef;
@@ -21,6 +22,8 @@ interface PlayerContextProps {
isVideoLoaded: boolean; isVideoLoaded: boolean;
tracksReady: boolean; tracksReady: boolean;
useVlcPlayer: boolean; useVlcPlayer: boolean;
offline: boolean;
downloadedItem: DownloadedItem | null;
} }
const PlayerContext = createContext<PlayerContextProps | undefined>(undefined); const PlayerContext = createContext<PlayerContextProps | undefined>(undefined);
@@ -33,6 +36,8 @@ interface PlayerProviderProps {
isVideoLoaded: boolean; isVideoLoaded: boolean;
tracksReady: boolean; tracksReady: boolean;
useVlcPlayer: boolean; useVlcPlayer: boolean;
offline?: boolean;
downloadedItem?: DownloadedItem | null;
} }
export const PlayerProvider: React.FC<PlayerProviderProps> = ({ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
@@ -43,6 +48,8 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
isVideoLoaded, isVideoLoaded,
tracksReady, tracksReady,
useVlcPlayer, useVlcPlayer,
offline = false,
downloadedItem = null,
}) => { }) => {
const value = useMemo( const value = useMemo(
() => ({ () => ({
@@ -52,8 +59,19 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
isVideoLoaded, isVideoLoaded,
tracksReady, tracksReady,
useVlcPlayer, useVlcPlayer,
offline,
downloadedItem,
}), }),
[playerRef, item, mediaSource, isVideoLoaded, tracksReady, useVlcPlayer], [
playerRef,
item,
mediaSource,
isVideoLoaded,
tracksReady,
useVlcPlayer,
offline,
downloadedItem,
],
); );
return ( return (

View File

@@ -75,7 +75,8 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null); const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null); const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const { tracksReady, mediaSource, useVlcPlayer } = usePlayerContext(); const { tracksReady, mediaSource, useVlcPlayer, offline, downloadedItem } =
usePlayerContext();
const playerControls = usePlayerControls(); const playerControls = usePlayerControls();
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } = const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
@@ -131,6 +132,86 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
if (!tracksReady) return; if (!tracksReady) return;
const fetchTracks = async () => { 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 // For VLC player, use simpler track handling with server indices
if (useVlcPlayer) { if (useVlcPlayer) {
// Get VLC track info (VLC returns TrackInfo[] with 'index' property) // Get VLC track info (VLC returns TrackInfo[] with 'index' property)
@@ -347,7 +428,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
}; };
fetchTracks(); fetchTracks();
}, [tracksReady, mediaSource, useVlcPlayer]); }, [tracksReady, mediaSource, useVlcPlayer, offline, downloadedItem]);
return ( return (
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}> <VideoContext.Provider value={{ subtitleTracks, audioTracks }}>

View File

@@ -235,6 +235,9 @@ export function useDownloadEventHandlers({
trickPlayData, trickPlayData,
introSegments, introSegments,
creditSegments, creditSegments,
audioStreamIndex,
subtitleStreamIndex,
isTranscoding,
} = process; } = process;
const videoFile = new File(filePathToUri(event.filePath)); const videoFile = new File(filePathToUri(event.filePath));
const fileInfo = videoFile.info(); const fileInfo = videoFile.info();
@@ -258,8 +261,9 @@ export function useDownloadEventHandlers({
introSegments, introSegments,
creditSegments, creditSegments,
userData: { userData: {
audioStreamIndex: 0, audioStreamIndex: audioStreamIndex ?? 0,
subtitleStreamIndex: 0, subtitleStreamIndex: subtitleStreamIndex ?? -1,
isTranscoded: isTranscoding ?? false,
}, },
}; };

View File

@@ -58,6 +58,8 @@ export function useDownloadOperations({
item: BaseItemDto, item: BaseItemDto,
mediaSource: MediaSourceInfo, mediaSource: MediaSourceInfo,
maxBitrate: Bitrate, maxBitrate: Bitrate,
audioStreamIndex?: number,
subtitleStreamIndex?: number,
) => { ) => {
if (!api || !item.Id || !authHeader) { if (!api || !item.Id || !authHeader) {
console.warn("startBackgroundDownload ~ Missing required params"); console.warn("startBackgroundDownload ~ Missing required params");
@@ -114,6 +116,8 @@ export function useDownloadOperations({
trickPlayData: additionalAssets.trickPlayData, trickPlayData: additionalAssets.trickPlayData,
introSegments: additionalAssets.introSegments, introSegments: additionalAssets.introSegments,
creditSegments: additionalAssets.creditSegments, creditSegments: additionalAssets.creditSegments,
audioStreamIndex,
subtitleStreamIndex,
}; };
// Add to processes // Add to processes

View File

@@ -21,6 +21,8 @@ interface UserData {
subtitleStreamIndex: number; subtitleStreamIndex: number;
/** The last known audio stream index. */ /** The last known audio stream index. */
audioStreamIndex: number; 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. */ /** Represents a segment of time in a media item, used for intro/credit skipping. */
@@ -142,4 +144,8 @@ export type JobStatus = {
introSegments?: MediaTimeSegment[]; introSegments?: MediaTimeSegment[];
/** Pre-downloaded credit segments (optional) - downloaded before video starts */ /** Pre-downloaded credit segments (optional) - downloaded before video starts */
creditSegments?: MediaTimeSegment[]; creditSegments?: MediaTimeSegment[];
/** The audio stream index selected for this download */
audioStreamIndex?: number;
/** The subtitle stream index selected for this download */
subtitleStreamIndex?: number;
}; };