mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
fix(downloads): preserve audio track selection for offline playback
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user