mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 02:04:35 +01:00
Compare commits
49 Commits
renovate/l
...
feat/local
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c54f9321c9 | ||
|
|
470e69774d | ||
|
|
8a0d43fc98 | ||
|
|
2e9c30580e | ||
|
|
6dd98ccae8 | ||
|
|
b4eb853048 | ||
|
|
2755f340c4 | ||
|
|
63ed386369 | ||
|
|
a38737df2e | ||
|
|
ce1e1dfc77 | ||
|
|
719c875293 | ||
|
|
d6d2cf6aca | ||
|
|
4389b56bd0 | ||
|
|
294568eef3 | ||
|
|
0ee5488308 | ||
|
|
c0908ad44d | ||
|
|
b5b29d434c | ||
|
|
626c0a98a8 | ||
|
|
2592cbdc48 | ||
|
|
b34afb0715 | ||
|
|
fd667c127f | ||
|
|
0e5c3215f3 | ||
|
|
16187aab90 | ||
|
|
2acdf1eea6 | ||
|
|
976af60185 | ||
|
|
6b90d3e868 | ||
|
|
de81b36725 | ||
|
|
b83b5b0bbb | ||
|
|
ec92e98b16 | ||
|
|
9c0de94247 | ||
|
|
0a58514964 | ||
|
|
58f8015e3b | ||
|
|
a27ea154ba | ||
|
|
6c3fa704db | ||
|
|
fe315699b9 | ||
|
|
c3271859b8 | ||
|
|
294b3f19c3 | ||
|
|
e9bb6b3c40 | ||
|
|
9d437e8cd1 | ||
|
|
ebf6e31478 | ||
|
|
378288bf08 | ||
|
|
92460cf202 | ||
|
|
feb5a41cff | ||
|
|
97607b2263 | ||
|
|
d3bc2ac5d5 | ||
|
|
96f6ad000b | ||
|
|
be575b7c04 | ||
|
|
91de36c3bd | ||
|
|
62f50590d4 |
233
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
233
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { TFunction } from "i18next";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
/**
|
||||
* Factory function to create skip options for a specific segment type
|
||||
* Reduces code duplication across all 5 segment types
|
||||
*/
|
||||
const useSkipOptions = (
|
||||
settingKey:
|
||||
| "skipIntro"
|
||||
| "skipOutro"
|
||||
| "skipRecap"
|
||||
| "skipCommercial"
|
||||
| "skipPreview",
|
||||
settings: ReturnType<typeof useSettings>["settings"] | null,
|
||||
updateSettings: ReturnType<typeof useSettings>["updateSettings"],
|
||||
t: TFunction<"translation", undefined>,
|
||||
) => {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
selected: option.value === settings?.[settingKey],
|
||||
onPress: () => updateSettings({ [settingKey]: option.value }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.[settingKey], updateSettings, t, settingKey],
|
||||
);
|
||||
};
|
||||
|
||||
export default function SegmentSkipPage() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: t("home.settings.other.segment_skip_settings"),
|
||||
});
|
||||
}, [navigation, t]);
|
||||
|
||||
const skipIntroOptions = useSkipOptions(
|
||||
"skipIntro",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipOutroOptions = useSkipOptions(
|
||||
"skipOutro",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipRecapOptions = useSkipOptions(
|
||||
"skipRecap",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipCommercialOptions = useSkipOptions(
|
||||
"skipCommercial",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipPreviewOptions = useSkipOptions(
|
||||
"skipPreview",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={false} className='px-4'>
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_intro")}
|
||||
subtitle={t("home.settings.other.skip_intro_description")}
|
||||
disabled={pluginSettings?.skipIntro?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipIntroOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.other.segment_skip_${settings.skipIntro}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_intro")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_outro")}
|
||||
subtitle={t("home.settings.other.skip_outro_description")}
|
||||
disabled={pluginSettings?.skipOutro?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipOutroOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.other.segment_skip_${settings.skipOutro}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_outro")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_recap")}
|
||||
subtitle={t("home.settings.other.skip_recap_description")}
|
||||
disabled={pluginSettings?.skipRecap?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipRecapOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.other.segment_skip_${settings.skipRecap}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_recap")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_commercial")}
|
||||
subtitle={t("home.settings.other.skip_commercial_description")}
|
||||
disabled={pluginSettings?.skipCommercial?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipCommercialOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.other.segment_skip_${settings.skipCommercial}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_commercial")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_preview")}
|
||||
subtitle={t("home.settings.other.skip_preview_description")}
|
||||
disabled={pluginSettings?.skipPreview?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipPreviewOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.other.segment_skip_${settings.skipPreview}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_preview")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
}
|
||||
|
||||
const SEGMENT_SKIP_OPTIONS = (
|
||||
t: TFunction<"translation", undefined>,
|
||||
): Array<{
|
||||
label: string;
|
||||
value: "none" | "ask" | "auto";
|
||||
}> => [
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_auto"),
|
||||
value: "auto",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_ask"),
|
||||
value: "ask",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_none"),
|
||||
value: "none",
|
||||
},
|
||||
];
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
RepeatMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getMediaInfoApi,
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
@@ -31,6 +32,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";
|
||||
@@ -60,8 +62,8 @@ import {
|
||||
getMpvSubtitleId,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToMs, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
export default function DirectPlayerPage() {
|
||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||
@@ -99,6 +101,8 @@ export default function DirectPlayerPage() {
|
||||
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");
|
||||
@@ -170,6 +174,14 @@ export default function DirectPlayerPage() {
|
||||
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,
|
||||
});
|
||||
|
||||
// Playback manager for progress reporting and adjacent items
|
||||
const playbackManager = usePlaybackManager({ item, isOffline: offline });
|
||||
const { nextItem, previousItem } = playbackManager;
|
||||
@@ -307,9 +319,43 @@ export default function DirectPlayerPage() {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Intro stream state - separate from main content stream
|
||||
const [introStream, setIntroStream] = useState<Stream | null>(null);
|
||||
|
||||
// Ref to store the stream fetch function for refreshing subtitle tracks
|
||||
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
|
||||
|
||||
// Live TV opens a server-side live stream via autoOpenLiveStream. If it is
|
||||
// never closed, Jellyfin's M3U tuner limit fills up and every channel then
|
||||
// fails with "simultaneous stream limit has been reached". reportPlaybackStopped
|
||||
// handles a clean stop, but a channel switch (the player re-fetches in place)
|
||||
// or an unmount after an error bypass it, so track the open live stream and
|
||||
// release it on those paths too.
|
||||
const apiRef = useRef(api);
|
||||
useEffect(() => {
|
||||
apiRef.current = api;
|
||||
}, [api]);
|
||||
|
||||
const releaseLiveStream = useCallback(
|
||||
(liveStreamId: string | null) => {
|
||||
if (!liveStreamId || !apiRef.current || offline) return;
|
||||
// Best effort: a failed close must not break teardown, and the slot is
|
||||
// also freed by the server's reap as a backstop.
|
||||
getMediaInfoApi(apiRef.current)
|
||||
.closeLiveStream({ liveStreamId })
|
||||
.catch(() => {});
|
||||
},
|
||||
[offline],
|
||||
);
|
||||
|
||||
// The effect cleanup releases the live stream both when it changes (channel
|
||||
// switch, which re-runs the effect) and when the player unmounts, so no
|
||||
// manual previous-id tracking is needed.
|
||||
useEffect(() => {
|
||||
const liveStreamId = stream?.mediaSource?.LiveStreamId ?? null;
|
||||
return () => releaseLiveStream(liveStreamId);
|
||||
}, [stream?.mediaSource?.LiveStreamId, releaseLiveStream]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStreamData = async (): Promise<Stream | null> => {
|
||||
setStreamStatus({ isLoading: true, isError: false });
|
||||
@@ -402,6 +448,57 @@ export default function DirectPlayerPage() {
|
||||
offline,
|
||||
]);
|
||||
|
||||
// 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 () => {
|
||||
@@ -445,6 +542,12 @@ export default function DirectPlayerPage() {
|
||||
MediaSourceId: mediaSourceId,
|
||||
PositionTicks: currentTimeInTicks,
|
||||
PlaySessionId: stream.sessionId,
|
||||
// Release the server-side live stream (and its tuner slot) on stop.
|
||||
// Jellyfin only closes a live stream opened via autoOpenLiveStream when
|
||||
// the stop report carries its LiveStreamId; without it the stream leaks
|
||||
// and Live TV eventually fails for everyone with "M3U simultaneous
|
||||
// stream limit has been reached". Undefined for non-live items (no-op).
|
||||
LiveStreamId: stream.mediaSource?.LiveStreamId ?? undefined,
|
||||
},
|
||||
});
|
||||
}, [api, item, mediaSourceId, stream, progress, offline]);
|
||||
@@ -568,6 +671,21 @@ export default function DirectPlayerPage() {
|
||||
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(
|
||||
@@ -584,6 +702,9 @@ export default function DirectPlayerPage() {
|
||||
isSeeking,
|
||||
isPlaybackStopped,
|
||||
isBuffering,
|
||||
isPlayingIntro,
|
||||
currentIntro,
|
||||
skipAllIntros,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -614,9 +735,11 @@ export default function DirectPlayerPage() {
|
||||
|
||||
/** 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
|
||||
@@ -652,14 +775,17 @@ export default function DirectPlayerPage() {
|
||||
);
|
||||
|
||||
// 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,
|
||||
@@ -705,6 +831,8 @@ export default function DirectPlayerPage() {
|
||||
}, [
|
||||
stream?.url,
|
||||
stream?.mediaSource,
|
||||
introStream?.url,
|
||||
introStream?.mediaSource,
|
||||
stream?.requiredHttpHeaders,
|
||||
item?.UserData?.PlaybackPositionTicks,
|
||||
playbackPositionFromUrl,
|
||||
@@ -713,6 +841,7 @@ export default function DirectPlayerPage() {
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
offline,
|
||||
isPlayingIntro,
|
||||
settings.mpvCacheEnabled,
|
||||
settings.mpvCacheSeconds,
|
||||
settings.mpvDemuxerMaxBytes,
|
||||
@@ -1407,6 +1536,9 @@ export default function DirectPlayerPage() {
|
||||
getTechnicalInfo={getTechnicalInfo}
|
||||
playMethod={playMethod}
|
||||
transcodeReasons={transcodeReasons}
|
||||
isPlayingIntro={isPlayingIntro}
|
||||
skipAllIntros={skipAllIntros}
|
||||
intros={intros}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
22
bun.lock
22
bun.lock
@@ -579,15 +579,15 @@
|
||||
|
||||
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.85.3", "", {}, "sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw=="],
|
||||
|
||||
"@react-navigation/core": ["@react-navigation/core@7.21.5", "", { "dependencies": { "@react-navigation/routers": "^7.6.0", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-3hpV7uR41LBW+GHDoLhztZCb/i5ySRJISZ/rez4d7DCHSZo6ej4gNxYclaS6LRguoLiKG7SOCNa6O390AQklZQ=="],
|
||||
"@react-navigation/core": ["@react-navigation/core@7.21.7", "", { "dependencies": { "@react-navigation/routers": "^7.6.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Z/L+IX6035XLHhGcQUgKdQzizh2XUuBjF6yZmxgamD/wRr/DbH+KuaD0hNikrHge/cHNRovlZ3gSChElGVuHlQ=="],
|
||||
|
||||
"@react-navigation/elements": ["@react-navigation/elements@2.9.30", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.3.8", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-2isleieiRMmP4WNMV2Q1u3qP1M47ZqsJ2hJ/Og11FeKXK8YmUTHya7PW7ecsgAh2CXKTxAcbfJDFTSvq2D++Tw=="],
|
||||
"@react-navigation/elements": ["@react-navigation/elements@2.9.32", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.3.10", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-sM04aRaAu3Fiqi+Q4dviv+rJ5VTLmJ6ZHQSA08cMSx1BXc7jdrZ40+laLxmNOPoWHgCv3k7KcAXp4HyB+nzQdw=="],
|
||||
|
||||
"@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.28", "", { "dependencies": { "@react-navigation/elements": "^2.9.19", "color": "^4.2.3", "react-native-tab-view": "^4.3.0" }, "peerDependencies": { "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-WZHJSGV2PQOD2Vr9LF8apGvcsbDKukzF3Fhh8xVNIesqaSi9TPProv4dRw6YkenUkjvFVZYkOjvwAJOToePVpA=="],
|
||||
|
||||
"@react-navigation/native": ["@react-navigation/native@7.3.8", "", { "dependencies": { "@react-navigation/core": "^7.21.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "standard-navigation": "^0.0.7", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-zHmQcxWBT8GOwsofEOmHqpdM5twkwE/esa9JFGlW4hpXeQTTe/dRcPSLjwsvePtolbDKz0YZbK+I5KrW3j63LQ=="],
|
||||
"@react-navigation/native": ["@react-navigation/native@7.3.10", "", { "dependencies": { "@react-navigation/core": "^7.21.7", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "standard-navigation": "^0.0.8", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-8MLJc6T72nbiJYg0bVictXlPSJAVJVPLowncxMmixyU3pAsxKGnAoLH9FhN91l1DwGfc5mxJzVEQZ/EmNILrRw=="],
|
||||
|
||||
"@react-navigation/routers": ["@react-navigation/routers@7.6.0", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow=="],
|
||||
"@react-navigation/routers": ["@react-navigation/routers@7.6.2", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-cYzWE/+kcX6RAaX1/peL07f4aIpxeE7uNDr89qJpfGuVz3WHs9dGE1QwbCyCK95fVYn1tvsOmbXbvR0o43uqKQ=="],
|
||||
|
||||
"@shopify/flash-list": ["@shopify/flash-list@2.0.3", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-jUlHuZFoPdqRCDvOqsb2YkTttRPyV8Tb/EjCx3gE2wjr4UTM+fE0Ltv9bwBg0K7yo/SxRNXaW7xu5utusRb0xA=="],
|
||||
|
||||
@@ -887,7 +887,7 @@
|
||||
|
||||
"dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||
|
||||
@@ -935,7 +935,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.392", "", {}, "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g=="],
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.393", "", {}, "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
@@ -1115,7 +1115,7 @@
|
||||
|
||||
"flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||
|
||||
"fontfaceobserver": ["fontfaceobserver@2.3.0", "", {}, "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg=="],
|
||||
|
||||
@@ -1583,7 +1583,7 @@
|
||||
|
||||
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
||||
|
||||
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
|
||||
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
|
||||
|
||||
"react-is": ["react-is@19.2.7", "", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="],
|
||||
|
||||
@@ -1603,7 +1603,7 @@
|
||||
|
||||
"react-native-draggable-flatlist": ["react-native-draggable-flatlist@4.0.3", "", { "dependencies": { "@babel/preset-typescript": "^7.17.12" }, "peerDependencies": { "react-native": ">=0.64.0", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=2.8.0" } }, "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw=="],
|
||||
|
||||
"react-native-drawer-layout": ["react-native-drawer-layout@4.2.7", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-gesture-handler": ">= 2.0.0", "react-native-reanimated": ">= 2.0.0" } }, "sha512-hhD+E0QmUPkP2Sj1MsUdrvU7GeOiHChPAFPtKahroTwlBGnpgJsUVSL0GWOy5cG3oCZfnu4Pb+gIzOa4ItGNuA=="],
|
||||
"react-native-drawer-layout": ["react-native-drawer-layout@4.2.8", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-gesture-handler": ">= 2.0.0", "react-native-reanimated": ">= 2.0.0" } }, "sha512-IswXoXEaQAnzUGo7tJyMPHP0qyUc/7zufpG+kpiStYegHpKQBBnlQoRdXkL1HbCa2o1ROWDhCSJ8XS0V4LNcjQ=="],
|
||||
|
||||
"react-native-edge-to-edge": ["react-native-edge-to-edge@1.8.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-bhvsKqeX9PGkY9wBUk9vni/tJNJdKtLPbs/j3e/3CdV4JmUWfTXYYoL+4Hc8Wmej+5eJxkc8KOFa454ruFWBCA=="],
|
||||
|
||||
@@ -1783,7 +1783,7 @@
|
||||
|
||||
"stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="],
|
||||
|
||||
"standard-navigation": ["standard-navigation@0.0.7", "", {}, "sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ=="],
|
||||
"standard-navigation": ["standard-navigation@0.0.8", "", { "peerDependencies": { "react": "*" } }, "sha512-TyVbo7INUDWtsUWDFn8RR7kwR87U0S4xHfLfbbnyeC581TmmyqQ+eM+nPw8rQTSD8QitRVcYfPaSHr/QJiUy1g=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
@@ -2083,6 +2083,8 @@
|
||||
|
||||
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
@@ -35,8 +35,10 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}
|
||||
if (item.Type === "Episode") {
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
// Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
|
||||
// backdrop owner — otherwise the Thumb tag is requested on the wrong item → black.
|
||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||
return `${api?.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
@@ -61,7 +63,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
}
|
||||
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}, [item]);
|
||||
// useEpisodePoster in deps so flipping the prop re-computes the URL live.
|
||||
}, [api, item, useEpisodePoster]);
|
||||
|
||||
if (!url)
|
||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||
|
||||
@@ -65,10 +65,11 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||
const posterUrl = useMemo(() => {
|
||||
if (!api) return null;
|
||||
|
||||
// For episodes, always use series thumb
|
||||
// For episodes, always use series thumb.
|
||||
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||
if (item.Type === "Episode") {
|
||||
if (item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
if (item.SeriesId) {
|
||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -15,6 +16,7 @@ import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const PlaybackControlsSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -251,6 +253,15 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{/* Media Segment Skip Settings */}
|
||||
<ListItem
|
||||
title={t("home.settings.other.segment_skip_settings")}
|
||||
subtitle={t("home.settings.other.segment_skip_settings_description")}
|
||||
onPress={() => router.push("/settings/segment-skip/page")}
|
||||
>
|
||||
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -139,9 +139,10 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
if (orientation === "horizontal") {
|
||||
// Episode: prefer series thumb image for consistent look (like hero section)
|
||||
if (item.Type === "Episode") {
|
||||
// First try parent/series thumb (horizontal series artwork)
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
// First try parent/series thumb (horizontal series artwork).
|
||||
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
// Fall back to episode's own primary image
|
||||
if (item.ImageTags?.Primary) {
|
||||
|
||||
@@ -35,7 +35,9 @@ interface BottomControlsProps {
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
showSkipButton: boolean;
|
||||
skipButtonText: string;
|
||||
showSkipCreditButton: boolean;
|
||||
skipCreditButtonText: string;
|
||||
hasContentAfterCredits: boolean;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
@@ -75,6 +77,11 @@ interface BottomControlsProps {
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
// Intro playback props
|
||||
isPlayingIntro?: boolean;
|
||||
skipAllIntros?: () => void;
|
||||
intros?: BaseItemDto[];
|
||||
}
|
||||
|
||||
export const BottomControls: FC<BottomControlsProps> = ({
|
||||
@@ -87,7 +94,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
currentTime,
|
||||
remainingTime,
|
||||
showSkipButton,
|
||||
skipButtonText,
|
||||
showSkipCreditButton,
|
||||
skipCreditButtonText,
|
||||
hasContentAfterCredits,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
@@ -108,6 +117,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
isPlayingIntro = false,
|
||||
skipAllIntros,
|
||||
intros = [],
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
@@ -180,10 +192,18 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex flex-row items-center 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
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText='Skip Intro'
|
||||
buttonText={skipButtonText}
|
||||
/>
|
||||
{/* Smart Skip Credits behavior:
|
||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||
@@ -193,7 +213,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||
}
|
||||
onPress={skipCredit}
|
||||
buttonText='Skip Credits'
|
||||
buttonText={skipCreditButtonText}
|
||||
/>
|
||||
{settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
|
||||
@@ -4,7 +4,15 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -16,17 +24,17 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, ticksToMs } from "@/utils/time";
|
||||
import { BottomControls } from "./BottomControls";
|
||||
import { CenterControls } from "./CenterControls";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
@@ -43,6 +51,9 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
|
||||
// No-op function to avoid creating new references on every render
|
||||
const noop = () => {};
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
isPlaying: boolean;
|
||||
@@ -73,6 +84,10 @@ interface Props {
|
||||
getTechnicalInfo?: () => Promise<TechnicalInfo>;
|
||||
playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
|
||||
transcodeReasons?: string[];
|
||||
// Intro playback props
|
||||
isPlayingIntro?: boolean;
|
||||
skipAllIntros?: () => void;
|
||||
intros?: BaseItemDto[];
|
||||
}
|
||||
|
||||
export const Controls: FC<Props> = ({
|
||||
@@ -102,6 +117,9 @@ export const Controls: FC<Props> = ({
|
||||
getTechnicalInfo,
|
||||
playMethod,
|
||||
transcodeReasons,
|
||||
isPlayingIntro = false,
|
||||
skipAllIntros,
|
||||
intros = [],
|
||||
}) => {
|
||||
const offline = useOfflineMode();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
@@ -111,6 +129,18 @@ export const Controls: FC<Props> = ({
|
||||
const [episodeView, setEpisodeView] = useState(false);
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
|
||||
// Ref to track pending play timeout for cleanup and cancellation
|
||||
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = usePlaybackManager({
|
||||
item,
|
||||
@@ -316,27 +346,122 @@ export const Controls: FC<Props> = ({
|
||||
subtitleIndex: string;
|
||||
}>();
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
// Fetch all segments for the current item
|
||||
const { data: segments } = useSegments(
|
||||
item.Id ?? "",
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
api,
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||
useCreditSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
maxMs,
|
||||
);
|
||||
// Convert milliseconds to seconds for segment comparison
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
||||
|
||||
// Wrapper to convert segment skip from seconds to milliseconds
|
||||
// Includes 200ms delay to allow seek operation to complete before resuming playback
|
||||
const seekMs = useCallback(
|
||||
(timeInSeconds: number) => {
|
||||
// Cancel any pending play call to avoid race conditions
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
seek(timeInSeconds * 1000);
|
||||
// Brief delay ensures the seek operation completes before resuming playback
|
||||
// Without this, playback may resume from the old position
|
||||
playTimeoutRef.current = setTimeout(() => {
|
||||
play();
|
||||
playTimeoutRef.current = null;
|
||||
}, 200);
|
||||
},
|
||||
[seek, play],
|
||||
);
|
||||
|
||||
// Use unified segment skipper for all segment types
|
||||
const introSkipper = useSegmentSkipper({
|
||||
segments: segments?.introSegments || [],
|
||||
segmentType: "Intro",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const outroSkipper = useSegmentSkipper({
|
||||
segments: segments?.creditSegments || [],
|
||||
segmentType: "Outro",
|
||||
currentTime: currentTimeSeconds,
|
||||
totalDuration: maxSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const recapSkipper = useSegmentSkipper({
|
||||
segments: segments?.recapSegments || [],
|
||||
segmentType: "Recap",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const commercialSkipper = useSegmentSkipper({
|
||||
segments: segments?.commercialSegments || [],
|
||||
segmentType: "Commercial",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const previewSkipper = useSegmentSkipper({
|
||||
segments: segments?.previewSegments || [],
|
||||
segmentType: "Preview",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
// Determine which segment button to show (priority order)
|
||||
// Commercial > Recap > Intro > Preview > Outro
|
||||
const activeSegment = useMemo(() => {
|
||||
if (commercialSkipper.currentSegment)
|
||||
return { type: "Commercial", ...commercialSkipper };
|
||||
if (recapSkipper.currentSegment) return { type: "Recap", ...recapSkipper };
|
||||
if (introSkipper.currentSegment) return { type: "Intro", ...introSkipper };
|
||||
if (previewSkipper.currentSegment)
|
||||
return { type: "Preview", ...previewSkipper };
|
||||
if (outroSkipper.currentSegment) return { type: "Outro", ...outroSkipper };
|
||||
return null;
|
||||
}, [
|
||||
commercialSkipper.currentSegment,
|
||||
recapSkipper.currentSegment,
|
||||
introSkipper.currentSegment,
|
||||
previewSkipper.currentSegment,
|
||||
outroSkipper.currentSegment,
|
||||
commercialSkipper,
|
||||
recapSkipper,
|
||||
introSkipper,
|
||||
previewSkipper,
|
||||
outroSkipper,
|
||||
]);
|
||||
|
||||
// Legacy compatibility: map to old variable names
|
||||
const showSkipButton = !!(
|
||||
activeSegment &&
|
||||
["Intro", "Recap", "Commercial", "Preview"].includes(activeSegment.type)
|
||||
);
|
||||
const skipIntro = activeSegment?.skipSegment || noop;
|
||||
const showSkipCreditButton = activeSegment?.type === "Outro";
|
||||
const skipCredit = outroSkipper.skipSegment;
|
||||
const hasContentAfterCredits =
|
||||
outroSkipper.currentSegment && maxSeconds
|
||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
||||
: false;
|
||||
|
||||
// Get button text based on segment type using i18n
|
||||
const { t } = useTranslation();
|
||||
const skipButtonText = activeSegment
|
||||
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
|
||||
: t("player.skip_intro");
|
||||
const skipCreditButtonText = t("player.skip_outro");
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
@@ -571,7 +696,9 @@ export const Controls: FC<Props> = ({
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
showSkipButton={showSkipButton}
|
||||
skipButtonText={skipButtonText}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
skipCreditButtonText={skipCreditButtonText}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={skipCredit}
|
||||
@@ -592,6 +719,9 @@ export const Controls: FC<Props> = ({
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
isPlayingIntro={isPlayingIntro}
|
||||
skipAllIntros={skipAllIntros}
|
||||
intros={intros}
|
||||
/>
|
||||
</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,
|
||||
};
|
||||
}
|
||||
105
hooks/useSegmentSkipper.ts
Normal file
105
hooks/useSegmentSkipper.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { MediaTimeSegment } from "@/providers/Downloads/types";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
||||
|
||||
interface UseSegmentSkipperProps {
|
||||
segments: MediaTimeSegment[];
|
||||
segmentType: SegmentType;
|
||||
currentTime: number;
|
||||
totalDuration?: number;
|
||||
seek: (time: number) => void;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
interface UseSegmentSkipperReturn {
|
||||
currentSegment: MediaTimeSegment | null;
|
||||
skipSegment: (notifyOrUseHaptics?: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic hook to handle all media segment types (intro, outro, recap, commercial, preview)
|
||||
* Supports three modes: 'none' (disabled), 'ask' (show button), 'auto' (auto-skip)
|
||||
*/
|
||||
export const useSegmentSkipper = ({
|
||||
segments,
|
||||
segmentType,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
seek,
|
||||
isPaused,
|
||||
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
|
||||
const { settings } = useSettings();
|
||||
const haptic = useHaptic();
|
||||
const autoSkipTriggeredRef = useRef(false);
|
||||
|
||||
// Get skip mode based on segment type
|
||||
const skipMode = (() => {
|
||||
switch (segmentType) {
|
||||
case "Intro":
|
||||
return settings.skipIntro;
|
||||
case "Outro":
|
||||
return settings.skipOutro;
|
||||
case "Recap":
|
||||
return settings.skipRecap;
|
||||
case "Commercial":
|
||||
return settings.skipCommercial;
|
||||
case "Preview":
|
||||
return settings.skipPreview;
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
})();
|
||||
|
||||
// Find current segment
|
||||
const currentSegment =
|
||||
segments.find(
|
||||
(segment) =>
|
||||
currentTime >= segment.startTime && currentTime < segment.endTime,
|
||||
) || null;
|
||||
|
||||
// Skip function with optional haptic feedback
|
||||
const skipSegment = useCallback(
|
||||
(notifyOrUseHaptics = true) => {
|
||||
if (!currentSegment) return;
|
||||
|
||||
// For Outro segments, prevent seeking past the end
|
||||
if (segmentType === "Outro" && totalDuration) {
|
||||
const seekTime = Math.min(currentSegment.endTime, totalDuration);
|
||||
seek(seekTime);
|
||||
} else {
|
||||
seek(currentSegment.endTime);
|
||||
}
|
||||
|
||||
// Only trigger haptic feedback if explicitly requested (manual skip)
|
||||
if (notifyOrUseHaptics) {
|
||||
haptic();
|
||||
}
|
||||
},
|
||||
[currentSegment, segmentType, totalDuration, seek, haptic],
|
||||
);
|
||||
// Auto-skip logic when mode is 'auto'
|
||||
useEffect(() => {
|
||||
if (skipMode !== "auto" || isPaused) {
|
||||
autoSkipTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSegment && !autoSkipTriggeredRef.current) {
|
||||
autoSkipTriggeredRef.current = true;
|
||||
skipSegment(false); // Don't trigger haptics for auto-skip
|
||||
}
|
||||
|
||||
if (!currentSegment) {
|
||||
autoSkipTriggeredRef.current = false;
|
||||
}
|
||||
}, [currentSegment, skipMode, isPaused, skipSegment]);
|
||||
|
||||
// Return null segment if skip mode is 'none'
|
||||
return {
|
||||
currentSegment: skipMode === "none" ? null : currentSegment,
|
||||
skipSegment,
|
||||
};
|
||||
};
|
||||
@@ -32,12 +32,6 @@ export interface MediaTimeSegment {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||
export interface DownloadedItem {
|
||||
/** The Jellyfin item DTO. */
|
||||
@@ -56,6 +50,12 @@ export interface DownloadedItem {
|
||||
introSegments?: MediaTimeSegment[];
|
||||
/** The credit segments for the item. */
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
/** The recap segments for the item. */
|
||||
recapSegments?: MediaTimeSegment[];
|
||||
/** The commercial segments for the item. */
|
||||
commercialSegments?: MediaTimeSegment[];
|
||||
/** The preview segments for the item. */
|
||||
previewSegments?: MediaTimeSegment[];
|
||||
/** The user data for the item. */
|
||||
userData: UserData;
|
||||
}
|
||||
@@ -144,6 +144,12 @@ export type JobStatus = {
|
||||
introSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded recap segments (optional) - downloaded before video starts */
|
||||
recapSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded commercial segments (optional) - downloaded before video starts */
|
||||
commercialSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded preview segments (optional) - downloaded before video starts */
|
||||
previewSegments?: MediaTimeSegment[];
|
||||
/** The audio stream index selected for this download */
|
||||
audioStreamIndex?: number;
|
||||
/** The subtitle stream index selected for this download */
|
||||
|
||||
@@ -295,6 +295,7 @@
|
||||
"UNKNOWN": "Unknown"
|
||||
},
|
||||
"safe_area_in_controls": "Safe area in controls",
|
||||
"video_player": "Video Player",
|
||||
"show_custom_menu_links": "Show custom menu links",
|
||||
"show_large_home_carousel": "Show large home carousel (beta)",
|
||||
"hide_libraries": "Hide libraries",
|
||||
@@ -304,6 +305,21 @@
|
||||
"default_playback_speed": "Default playback speed",
|
||||
"auto_play_next_episode": "Auto-play next episode",
|
||||
"max_auto_play_episode_count": "Max auto-play episode count",
|
||||
"segment_skip_settings": "Segment Skip Settings",
|
||||
"segment_skip_settings_description": "Configure skip behavior for intros, credits, and other segments",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_intro_description": "Action when intro segment is detected",
|
||||
"skip_outro": "Skip Outro/Credits",
|
||||
"skip_outro_description": "Action when outro/credits segment is detected",
|
||||
"skip_recap": "Skip Recap",
|
||||
"skip_recap_description": "Action when recap segment is detected",
|
||||
"skip_commercial": "Skip Commercial",
|
||||
"skip_commercial_description": "Action when commercial segment is detected",
|
||||
"skip_preview": "Skip Preview",
|
||||
"skip_preview_description": "Action when preview segment is detected",
|
||||
"segment_skip_none": "None",
|
||||
"segment_skip_ask": "Show Skip Button",
|
||||
"segment_skip_auto": "Auto Skip",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"music": {
|
||||
@@ -628,6 +644,10 @@
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"skip_intro": "Skip intro",
|
||||
"skip_outro": "Skip outro",
|
||||
"skip_recap": "Skip recap",
|
||||
"skip_commercial": "Skip commercial",
|
||||
"skip_preview": "Skip preview",
|
||||
"skip_credits": "Skip credits",
|
||||
"stopPlayback": "Stop playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
|
||||
@@ -176,6 +176,9 @@ export enum VideoPlayer {
|
||||
MPV = 0,
|
||||
}
|
||||
|
||||
// Segment skip behavior options
|
||||
export type SegmentSkipMode = "none" | "ask" | "auto";
|
||||
|
||||
// TV Typography scale presets
|
||||
export enum TVTypographyScale {
|
||||
Small = "small",
|
||||
@@ -247,6 +250,12 @@ export type Settings = {
|
||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||
autoPlayEpisodeCount: number;
|
||||
autoPlayNextEpisode: boolean;
|
||||
// Media segment skip preferences
|
||||
skipIntro: SegmentSkipMode;
|
||||
skipOutro: SegmentSkipMode;
|
||||
skipRecap: SegmentSkipMode;
|
||||
skipCommercial: SegmentSkipMode;
|
||||
skipPreview: SegmentSkipMode;
|
||||
// Playback speed settings
|
||||
defaultPlaybackSpeed: number;
|
||||
playbackSpeedPerMedia: Record<string, number>;
|
||||
@@ -350,6 +359,12 @@ export const defaultValues: Settings = {
|
||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||
autoPlayEpisodeCount: 0,
|
||||
autoPlayNextEpisode: true,
|
||||
// Media segment skip defaults
|
||||
skipIntro: "ask",
|
||||
skipOutro: "ask",
|
||||
skipRecap: "ask",
|
||||
skipCommercial: "ask",
|
||||
skipPreview: "ask",
|
||||
// Playback speed defaults
|
||||
defaultPlaybackSpeed: 1.0,
|
||||
playbackSpeedPerMedia: {},
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
@@ -74,10 +74,16 @@ export const getSegmentsForItem = (
|
||||
): {
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
recapSegments: MediaTimeSegment[];
|
||||
commercialSegments: MediaTimeSegment[];
|
||||
previewSegments: MediaTimeSegment[];
|
||||
} => {
|
||||
return {
|
||||
introSegments: item.introSegments || [],
|
||||
creditSegments: item.creditSegments || [],
|
||||
recapSegments: item.recapSegments || [],
|
||||
commercialSegments: item.commercialSegments || [],
|
||||
previewSegments: item.previewSegments || [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -95,6 +101,9 @@ const fetchMediaSegments = async (
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
recapSegments: MediaTimeSegment[];
|
||||
commercialSegments: MediaTimeSegment[];
|
||||
previewSegments: MediaTimeSegment[];
|
||||
} | null> => {
|
||||
try {
|
||||
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
||||
@@ -102,13 +111,22 @@ const fetchMediaSegments = async (
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
params: {
|
||||
includeSegmentTypes: ["Intro", "Outro"],
|
||||
includeSegmentTypes: [
|
||||
"Intro",
|
||||
"Outro",
|
||||
"Recap",
|
||||
"Commercial",
|
||||
"Preview",
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const introSegments: MediaTimeSegment[] = [];
|
||||
const creditSegments: MediaTimeSegment[] = [];
|
||||
const recapSegments: MediaTimeSegment[] = [];
|
||||
const commercialSegments: MediaTimeSegment[] = [];
|
||||
const previewSegments: MediaTimeSegment[] = [];
|
||||
|
||||
response.data.Items.forEach((segment) => {
|
||||
const timeSegment: MediaTimeSegment = {
|
||||
@@ -124,13 +142,27 @@ const fetchMediaSegments = async (
|
||||
case "Outro":
|
||||
creditSegments.push(timeSegment);
|
||||
break;
|
||||
// Optionally handle other types like Recap, Commercial, Preview
|
||||
case "Recap":
|
||||
recapSegments.push(timeSegment);
|
||||
break;
|
||||
case "Commercial":
|
||||
commercialSegments.push(timeSegment);
|
||||
break;
|
||||
case "Preview":
|
||||
previewSegments.push(timeSegment);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return { introSegments, creditSegments };
|
||||
return {
|
||||
introSegments,
|
||||
creditSegments,
|
||||
recapSegments,
|
||||
commercialSegments,
|
||||
previewSegments,
|
||||
};
|
||||
} catch (_error) {
|
||||
// Return null to indicate we should try legacy endpoints
|
||||
return null;
|
||||
@@ -146,6 +178,9 @@ const fetchLegacySegments = async (
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
recapSegments: MediaTimeSegment[];
|
||||
commercialSegments: MediaTimeSegment[];
|
||||
previewSegments: MediaTimeSegment[];
|
||||
}> => {
|
||||
const introSegments: MediaTimeSegment[] = [];
|
||||
const creditSegments: MediaTimeSegment[] = [];
|
||||
@@ -184,7 +219,13 @@ const fetchLegacySegments = async (
|
||||
console.error("Failed to fetch legacy segments", error);
|
||||
}
|
||||
|
||||
return { introSegments, creditSegments };
|
||||
return {
|
||||
introSegments,
|
||||
creditSegments,
|
||||
recapSegments: [],
|
||||
commercialSegments: [],
|
||||
previewSegments: [],
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchAndParseSegments = async (
|
||||
@@ -193,6 +234,9 @@ export const fetchAndParseSegments = async (
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
recapSegments: MediaTimeSegment[];
|
||||
commercialSegments: MediaTimeSegment[];
|
||||
previewSegments: MediaTimeSegment[];
|
||||
}> => {
|
||||
// Try new API first (Jellyfin 10.11+)
|
||||
const newSegments = await fetchMediaSegments(itemId, api);
|
||||
|
||||
Reference in New Issue
Block a user