feat: adding support for local intros

Adding logic to be able to play local intros before content plays

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
Lance Chant
2026-02-13 16:23:32 +02:00
committed by Gauvain
parent d6d2cf6aca
commit 719c875293
6 changed files with 202 additions and 9 deletions

View File

@@ -28,6 +28,7 @@ import {
} from "@/components/video-player/controls/utils/playback-speed-settings";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroPlayback } from "@/hooks/useIntroPlayback";
import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
@@ -55,7 +56,7 @@ import {
} from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import { msToTicks, ticksToMs, ticksToSeconds } from "@/utils/time";
export default function page() {
const videoRef = useRef<MpvPlayerViewRef>(null);
@@ -87,6 +88,8 @@ export default function page() {
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
// Track whether we've already triggered completion for the current intro
const introCompletionTriggered = useSharedValue(false);
const VolumeManager = Platform.isTV
? null
: require("react-native-volume-manager");
@@ -149,6 +152,14 @@ export default function page() {
isError: false,
});
// Intro playback hook - manages intro video playback before main content
const { intros, currentIntro, isPlayingIntro, skipAllIntros } =
useIntroPlayback({
api,
itemId: item?.Id || null,
userId: user?.Id,
});
// Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
const audioIndex = useMemo(() => {
if (audioIndexFromUrl !== undefined) {
@@ -247,6 +258,9 @@ export default function page() {
isError: false,
});
// Intro stream state - separate from main content stream
const [introStream, setIntroStream] = useState<Stream | null>(null);
useEffect(() => {
const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false });
@@ -327,6 +341,57 @@ export default function page() {
downloadedItem,
]);
// Fetch intro stream when current intro changes
useEffect(() => {
const fetchIntroStreamData = async () => {
// Don't fetch intro stream if offline or no current intro
if (offline || !currentIntro?.Id || !api || !user?.Id) {
setIntroStream(null);
return;
}
try {
const res = await getStreamUrl({
api,
item: currentIntro,
startTimeTicks: 0, // Always start from beginning for intros
userId: user.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: undefined,
subtitleStreamIndex: subtitleIndex,
deviceProfile: generateDeviceProfile(),
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
console.error("Failed to get intro stream URL");
return;
}
setIntroStream({ mediaSource, sessionId, url });
} catch (error) {
console.error("Failed to fetch intro stream:", error);
}
};
fetchIntroStreamData();
}, [
currentIntro,
api,
user?.Id,
audioIndex,
bitrateValue,
subtitleIndex,
offline,
]);
// Reset intro completion flag when a new intro starts playing
useEffect(() => {
if (isPlayingIntro) {
introCompletionTriggered.value = false;
}
}, [isPlayingIntro, currentIntro]);
useEffect(() => {
if (!stream || !api || offline) return;
const reportPlaybackStart = async () => {
@@ -449,7 +514,7 @@ export default function page() {
async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { position, cacheSeconds } = data.nativeEvent;
const { position, duration, cacheSeconds } = data.nativeEvent;
// MPV reports position in seconds, convert to ms
const currentTime = position * 1000;
@@ -480,6 +545,21 @@ export default function page() {
lastUrlUpdateTime.value = now;
}
// Handle intro completion - check if intro has reached its end
if (isPlayingIntro && currentIntro) {
const introDuration = ticksToMs(currentIntro.RunTimeTicks || 0);
// Check if we're near the end of the intro (within 1000ms buffer)
// Use a larger buffer to ensure reliable detection even with short intros
// or if MPV doesn't fire progress callbacks frequently
if (currentTime >= introDuration - 1000) {
// Only trigger once per intro to avoid multiple calls
if (!introCompletionTriggered.value) {
introCompletionTriggered.value = true;
skipAllIntros();
}
}
}
if (!item?.Id) return;
playbackManager.reportPlaybackProgress(
@@ -496,6 +576,9 @@ export default function page() {
isSeeking,
isPlaybackStopped,
isBuffering,
isPlayingIntro,
currentIntro,
skipAllIntros,
],
);
@@ -506,9 +589,11 @@ export default function page() {
/** Build video source config for MPV */
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
if (!stream?.url) return undefined;
// Use intro stream if playing intro, otherwise use main content stream
const activeStream = isPlayingIntro ? introStream : stream;
if (!activeStream?.url) return undefined;
const mediaSource = stream.mediaSource;
const mediaSource = activeStream.mediaSource;
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
// Get external subtitle URLs
@@ -544,14 +629,17 @@ export default function page() {
);
// Calculate start position directly here to avoid timing issues
const startTicks = playbackPositionFromUrl
? Number.parseInt(playbackPositionFromUrl, 10)
: (item?.UserData?.PlaybackPositionTicks ?? 0);
// For intros, always start from 0
const startTicks = isPlayingIntro
? 0
: playbackPositionFromUrl
? Number.parseInt(playbackPositionFromUrl, 10)
: (item?.UserData?.PlaybackPositionTicks ?? 0);
const startPos = ticksToSeconds(startTicks);
// Build source config - headers only needed for online streaming
const source: MpvVideoSource = {
url: stream.url,
url: activeStream.url,
startPosition: startPos,
autoplay: true,
initialSubtitleId,
@@ -574,6 +662,8 @@ export default function page() {
}, [
stream?.url,
stream?.mediaSource,
introStream?.url,
introStream?.mediaSource,
item?.UserData?.PlaybackPositionTicks,
playbackPositionFromUrl,
api?.basePath,
@@ -581,6 +671,7 @@ export default function page() {
subtitleIndex,
audioIndex,
offline,
isPlayingIntro,
]);
const volumeUpCb = useCallback(async () => {
@@ -993,6 +1084,9 @@ export default function page() {
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
isPlayingIntro={isPlayingIntro}
skipAllIntros={skipAllIntros}
intros={intros}
/>
)}
</View>