mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-14 16:22:23 +00:00
Compare commits
5 Commits
fix/refres
...
feat/local
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b90d3e868 | ||
|
|
de81b36725 | ||
|
|
b83b5b0bbb | ||
|
|
ec92e98b16 | ||
|
|
9c0de94247 |
@@ -28,6 +28,7 @@ import {
|
|||||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useIntroPlayback } from "@/hooks/useIntroPlayback";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
|
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
|
||||||
@@ -55,7 +56,7 @@ import {
|
|||||||
} from "@/utils/jellyfin/subtitleUtils";
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToMs, ticksToSeconds } from "@/utils/time";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||||
@@ -87,6 +88,8 @@ export default function page() {
|
|||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
|
// Track whether we've already triggered completion for the current intro
|
||||||
|
const introCompletionTriggered = useSharedValue(false);
|
||||||
const VolumeManager = Platform.isTV
|
const VolumeManager = Platform.isTV
|
||||||
? null
|
? null
|
||||||
: require("react-native-volume-manager");
|
: require("react-native-volume-manager");
|
||||||
@@ -149,6 +152,14 @@ export default function page() {
|
|||||||
isError: false,
|
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
|
// Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||||
const audioIndex = useMemo(() => {
|
const audioIndex = useMemo(() => {
|
||||||
if (audioIndexFromUrl !== undefined) {
|
if (audioIndexFromUrl !== undefined) {
|
||||||
@@ -247,6 +258,9 @@ export default function page() {
|
|||||||
isError: false,
|
isError: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Intro stream state - separate from main content stream
|
||||||
|
const [introStream, setIntroStream] = useState<Stream | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStreamData = async () => {
|
const fetchStreamData = async () => {
|
||||||
setStreamStatus({ isLoading: true, isError: false });
|
setStreamStatus({ isLoading: true, isError: false });
|
||||||
@@ -327,6 +341,57 @@ export default function page() {
|
|||||||
downloadedItem,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!stream || !api || offline) return;
|
if (!stream || !api || offline) return;
|
||||||
const reportPlaybackStart = async () => {
|
const reportPlaybackStart = async () => {
|
||||||
@@ -480,6 +545,21 @@ export default function page() {
|
|||||||
lastUrlUpdateTime.value = now;
|
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;
|
if (!item?.Id) return;
|
||||||
|
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
@@ -496,6 +576,9 @@ export default function page() {
|
|||||||
isSeeking,
|
isSeeking,
|
||||||
isPlaybackStopped,
|
isPlaybackStopped,
|
||||||
isBuffering,
|
isBuffering,
|
||||||
|
isPlayingIntro,
|
||||||
|
currentIntro,
|
||||||
|
skipAllIntros,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -506,9 +589,11 @@ export default function page() {
|
|||||||
|
|
||||||
/** Build video source config for MPV */
|
/** Build video source config for MPV */
|
||||||
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
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);
|
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
// Get external subtitle URLs
|
// Get external subtitle URLs
|
||||||
@@ -544,14 +629,17 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Calculate start position directly here to avoid timing issues
|
// Calculate start position directly here to avoid timing issues
|
||||||
const startTicks = playbackPositionFromUrl
|
// For intros, always start from 0
|
||||||
? Number.parseInt(playbackPositionFromUrl, 10)
|
const startTicks = isPlayingIntro
|
||||||
: (item?.UserData?.PlaybackPositionTicks ?? 0);
|
? 0
|
||||||
|
: playbackPositionFromUrl
|
||||||
|
? Number.parseInt(playbackPositionFromUrl, 10)
|
||||||
|
: (item?.UserData?.PlaybackPositionTicks ?? 0);
|
||||||
const startPos = ticksToSeconds(startTicks);
|
const startPos = ticksToSeconds(startTicks);
|
||||||
|
|
||||||
// Build source config - headers only needed for online streaming
|
// Build source config - headers only needed for online streaming
|
||||||
const source: MpvVideoSource = {
|
const source: MpvVideoSource = {
|
||||||
url: stream.url,
|
url: activeStream.url,
|
||||||
startPosition: startPos,
|
startPosition: startPos,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
initialSubtitleId,
|
initialSubtitleId,
|
||||||
@@ -574,6 +662,8 @@ export default function page() {
|
|||||||
}, [
|
}, [
|
||||||
stream?.url,
|
stream?.url,
|
||||||
stream?.mediaSource,
|
stream?.mediaSource,
|
||||||
|
introStream?.url,
|
||||||
|
introStream?.mediaSource,
|
||||||
item?.UserData?.PlaybackPositionTicks,
|
item?.UserData?.PlaybackPositionTicks,
|
||||||
playbackPositionFromUrl,
|
playbackPositionFromUrl,
|
||||||
api?.basePath,
|
api?.basePath,
|
||||||
@@ -581,6 +671,7 @@ export default function page() {
|
|||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
offline,
|
offline,
|
||||||
|
isPlayingIntro,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const volumeUpCb = useCallback(async () => {
|
const volumeUpCb = useCallback(async () => {
|
||||||
@@ -993,6 +1084,9 @@ export default function page() {
|
|||||||
getTechnicalInfo={getTechnicalInfo}
|
getTechnicalInfo={getTechnicalInfo}
|
||||||
playMethod={playMethod}
|
playMethod={playMethod}
|
||||||
transcodeReasons={transcodeReasons}
|
transcodeReasons={transcodeReasons}
|
||||||
|
isPlayingIntro={isPlayingIntro}
|
||||||
|
skipAllIntros={skipAllIntros}
|
||||||
|
intros={intros}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
1
bun.lock
1
bun.lock
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 0,
|
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ interface BottomControlsProps {
|
|||||||
minutes: number;
|
minutes: number;
|
||||||
seconds: number;
|
seconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Intro playback props
|
||||||
|
isPlayingIntro?: boolean;
|
||||||
|
skipAllIntros?: () => void;
|
||||||
|
intros?: BaseItemDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BottomControls: FC<BottomControlsProps> = ({
|
export const BottomControls: FC<BottomControlsProps> = ({
|
||||||
@@ -87,6 +92,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
time,
|
time,
|
||||||
|
isPlayingIntro = false,
|
||||||
|
skipAllIntros,
|
||||||
|
intros = [],
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -133,6 +141,14 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row space-x-2 shrink-0'>
|
<View className='flex flex-row space-x-2 shrink-0'>
|
||||||
|
{/* Skip Intro button - shows when playing intro videos */}
|
||||||
|
{isPlayingIntro && intros.length > 0 && skipAllIntros && (
|
||||||
|
<SkipButton
|
||||||
|
showButton={true}
|
||||||
|
onPress={skipAllIntros}
|
||||||
|
buttonText='Skip Intro'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ interface Props {
|
|||||||
getTechnicalInfo?: () => Promise<TechnicalInfo>;
|
getTechnicalInfo?: () => Promise<TechnicalInfo>;
|
||||||
playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
|
playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
|
||||||
transcodeReasons?: string[];
|
transcodeReasons?: string[];
|
||||||
|
// Intro playback props
|
||||||
|
isPlayingIntro?: boolean;
|
||||||
|
skipAllIntros?: () => void;
|
||||||
|
intros?: BaseItemDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Controls: FC<Props> = ({
|
export const Controls: FC<Props> = ({
|
||||||
@@ -101,6 +105,9 @@ export const Controls: FC<Props> = ({
|
|||||||
getTechnicalInfo,
|
getTechnicalInfo,
|
||||||
playMethod,
|
playMethod,
|
||||||
transcodeReasons,
|
transcodeReasons,
|
||||||
|
isPlayingIntro = false,
|
||||||
|
skipAllIntros,
|
||||||
|
intros = [],
|
||||||
}) => {
|
}) => {
|
||||||
const offline = useOfflineMode();
|
const offline = useOfflineMode();
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
@@ -554,6 +561,9 @@ export const Controls: FC<Props> = ({
|
|||||||
trickPlayUrl={trickPlayUrl}
|
trickPlayUrl={trickPlayUrl}
|
||||||
trickplayInfo={trickplayInfo}
|
trickplayInfo={trickplayInfo}
|
||||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||||
|
isPlayingIntro={isPlayingIntro}
|
||||||
|
skipAllIntros={skipAllIntros}
|
||||||
|
intros={intros}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
46
hooks/useIntroPlayback.ts
Normal file
46
hooks/useIntroPlayback.ts
Normal file
@@ -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<BaseItemDto[]>([]);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
28
utils/intros.ts
Normal file
28
utils/intros.ts
Normal file
@@ -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<BaseItemDto[]> - Array of intro items
|
||||||
|
*/
|
||||||
|
export async function getIntros(
|
||||||
|
api: Api,
|
||||||
|
itemId: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<BaseItemDto[]> {
|
||||||
|
try {
|
||||||
|
const response = await getUserLibraryApi(api).getIntros({
|
||||||
|
itemId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching intros:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user