diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index b5dcac73..580fa7bd 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -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(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(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(() => { - 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} /> )} diff --git a/bun.lock b/bun.lock index 326bd749..0b19b52f 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "streamyfin", diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 51abf68c..52e6b895 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -57,6 +57,11 @@ interface BottomControlsProps { minutes: number; seconds: number; }; + + // Intro playback props + isPlayingIntro?: boolean; + skipAllIntros?: () => void; + intros?: BaseItemDto[]; } export const BottomControls: FC = ({ @@ -87,6 +92,9 @@ export const BottomControls: FC = ({ trickPlayUrl, trickplayInfo, time, + isPlayingIntro = false, + skipAllIntros, + intros = [], }) => { const { settings } = useSettings(); const insets = useSafeAreaInsets(); @@ -133,6 +141,14 @@ export const BottomControls: FC = ({ )} + {/* Skip Intro button - shows when playing intro videos */} + {isPlayingIntro && intros.length > 0 && skipAllIntros && ( + + )} Promise; playMethod?: "DirectPlay" | "DirectStream" | "Transcode"; transcodeReasons?: string[]; + // Intro playback props + isPlayingIntro?: boolean; + skipAllIntros?: () => void; + intros?: BaseItemDto[]; } export const Controls: FC = ({ @@ -101,6 +105,9 @@ export const Controls: FC = ({ getTechnicalInfo, playMethod, transcodeReasons, + isPlayingIntro = false, + skipAllIntros, + intros = [], }) => { const offline = useOfflineMode(); const { settings, updateSettings } = useSettings(); @@ -554,6 +561,9 @@ export const Controls: FC = ({ trickPlayUrl={trickPlayUrl} trickplayInfo={trickplayInfo} time={isSliding || showRemoteBubble ? time : remoteTime} + isPlayingIntro={isPlayingIntro} + skipAllIntros={skipAllIntros} + intros={intros} /> diff --git a/hooks/useIntroPlayback.ts b/hooks/useIntroPlayback.ts new file mode 100644 index 00000000..a566a127 --- /dev/null +++ b/hooks/useIntroPlayback.ts @@ -0,0 +1,46 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { useEffect, useState } from "react"; +import { getIntros } from "@/utils/intros"; + +interface UseIntroPlaybackProps { + api: Api | null; + itemId: string | null; + userId?: string; +} + +export function useIntroPlayback({ + api, + itemId, + userId, +}: UseIntroPlaybackProps) { + const [intros, setIntros] = useState([]); + const [isPlayingIntro, setIsPlayingIntro] = useState(false); + + useEffect(() => { + async function fetchIntros() { + if (!api || !itemId) return; + + const introItems = await getIntros(api, itemId, userId); + setIntros(introItems); + // Set isPlayingIntro to true when intros are available + setIsPlayingIntro(introItems.length > 0); + } + + fetchIntros(); + }, [api, itemId, userId]); + + // Only play the first intro if intros are available.. might be nice to configure this at some point with tags or something 🤷‍♂️ + const currentIntro = intros.length > 0 ? intros[0] : null; + + const skipAllIntros = () => { + setIsPlayingIntro(false); + }; + + return { + intros, + currentIntro, + isPlayingIntro, + skipAllIntros, + }; +} diff --git a/utils/intros.ts b/utils/intros.ts new file mode 100644 index 00000000..3553e078 --- /dev/null +++ b/utils/intros.ts @@ -0,0 +1,28 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; + +/** + * Fetches intro items for a given media item using the Jellyfin SDK + * @param api - The Jellyfin API instance + * @param itemId - The ID of the media item + * @param userId - Optional user ID + * @returns Promise - Array of intro items + */ +export async function getIntros( + api: Api, + itemId: string, + userId?: string, +): Promise { + try { + const response = await getUserLibraryApi(api).getIntros({ + itemId, + userId, + }); + + return response.data.Items || []; + } catch (error) { + console.error("Error fetching intros:", error); + return []; + } +}