mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-21 11:32:24 +00:00
Compare commits
4 Commits
feat/local
...
sync-subti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccdd7770c9 | ||
|
|
24cb679c0b | ||
|
|
af50b023ef | ||
|
|
9f9d949891 |
@@ -1,233 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -28,7 +28,6 @@ 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";
|
||||||
@@ -49,6 +48,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import {
|
import {
|
||||||
getMpvAudioId,
|
getMpvAudioId,
|
||||||
@@ -56,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, ticksToMs, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||||
@@ -88,8 +88,6 @@ 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");
|
||||||
@@ -136,7 +134,7 @@ export default function page() {
|
|||||||
const audioIndexFromUrl = audioIndexStr
|
const audioIndexFromUrl = audioIndexStr
|
||||||
? Number.parseInt(audioIndexStr, 10)
|
? Number.parseInt(audioIndexStr, 10)
|
||||||
: undefined;
|
: undefined;
|
||||||
const subtitleIndex = subtitleIndexStr
|
const subtitleIndexFromUrl = subtitleIndexStr
|
||||||
? Number.parseInt(subtitleIndexStr, 10)
|
? Number.parseInt(subtitleIndexStr, 10)
|
||||||
: -1;
|
: -1;
|
||||||
const bitrateValue = bitrateValueStr
|
const bitrateValue = bitrateValueStr
|
||||||
@@ -152,14 +150,6 @@ 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) {
|
||||||
@@ -171,6 +161,24 @@ export default function page() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
|
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
|
||||||
|
|
||||||
|
// Resolve subtitle index: use URL param if provided, otherwise use stored index for offline playback
|
||||||
|
const subtitleIndex = useMemo(() => {
|
||||||
|
if (subtitleIndexFromUrl !== undefined) {
|
||||||
|
return subtitleIndexFromUrl;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
offline &&
|
||||||
|
downloadedItem?.userData?.subtitleStreamIndex !== undefined
|
||||||
|
) {
|
||||||
|
return downloadedItem.userData.subtitleStreamIndex;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}, [
|
||||||
|
subtitleIndexFromUrl,
|
||||||
|
offline,
|
||||||
|
downloadedItem?.userData?.subtitleStreamIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
// Get the playback speed for this item based on settings
|
// Get the playback speed for this item based on settings
|
||||||
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
|
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
|
||||||
item,
|
item,
|
||||||
@@ -258,9 +266,6 @@ 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 });
|
||||||
@@ -341,57 +346,6 @@ 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 () => {
|
||||||
@@ -470,8 +424,8 @@ export default function page() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ItemId: item.Id,
|
ItemId: item.Id,
|
||||||
AudioStreamIndex: audioIndex ? audioIndex : undefined,
|
AudioStreamIndex: audioIndex,
|
||||||
SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
SubtitleStreamIndex: subtitleIndex,
|
||||||
MediaSourceId: mediaSourceId,
|
MediaSourceId: mediaSourceId,
|
||||||
PositionTicks: msToTicks(progress.get()),
|
PositionTicks: msToTicks(progress.get()),
|
||||||
IsPaused: !isPlaying,
|
IsPaused: !isPlaying,
|
||||||
@@ -545,21 +499,6 @@ 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(
|
||||||
@@ -576,9 +515,6 @@ export default function page() {
|
|||||||
isSeeking,
|
isSeeking,
|
||||||
isPlaybackStopped,
|
isPlaybackStopped,
|
||||||
isBuffering,
|
isBuffering,
|
||||||
isPlayingIntro,
|
|
||||||
currentIntro,
|
|
||||||
skipAllIntros,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -587,13 +523,36 @@ export default function page() {
|
|||||||
return ticksToSeconds(getInitialPlaybackTicks());
|
return ticksToSeconds(getInitialPlaybackTicks());
|
||||||
}, [getInitialPlaybackTicks]);
|
}, [getInitialPlaybackTicks]);
|
||||||
|
|
||||||
|
/** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */
|
||||||
|
const nowPlayingMetadata = useMemo(() => {
|
||||||
|
if (!item || !api) return undefined;
|
||||||
|
|
||||||
|
const artworkUri = getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: item.Name || "",
|
||||||
|
artist:
|
||||||
|
item.Type === "Episode"
|
||||||
|
? item.SeriesName || ""
|
||||||
|
: item.AlbumArtist || "",
|
||||||
|
albumTitle:
|
||||||
|
item.Type === "Episode" && item.SeasonName
|
||||||
|
? item.SeasonName
|
||||||
|
: undefined,
|
||||||
|
artworkUri: artworkUri || undefined,
|
||||||
|
};
|
||||||
|
}, [item, api]);
|
||||||
|
|
||||||
/** Build video source config for MPV */
|
/** Build video source config for MPV */
|
||||||
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
||||||
// Use intro stream if playing intro, otherwise use main content stream
|
if (!stream?.url) return undefined;
|
||||||
const activeStream = isPlayingIntro ? introStream : stream;
|
|
||||||
if (!activeStream?.url) return undefined;
|
|
||||||
|
|
||||||
const mediaSource = activeStream.mediaSource;
|
const mediaSource = stream.mediaSource;
|
||||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
// Get external subtitle URLs
|
// Get external subtitle URLs
|
||||||
@@ -629,17 +588,14 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Calculate start position directly here to avoid timing issues
|
// Calculate start position directly here to avoid timing issues
|
||||||
// For intros, always start from 0
|
const startTicks = playbackPositionFromUrl
|
||||||
const startTicks = isPlayingIntro
|
? Number.parseInt(playbackPositionFromUrl, 10)
|
||||||
? 0
|
: (item?.UserData?.PlaybackPositionTicks ?? 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: activeStream.url,
|
url: stream.url,
|
||||||
startPosition: startPos,
|
startPosition: startPos,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
initialSubtitleId,
|
initialSubtitleId,
|
||||||
@@ -662,8 +618,6 @@ 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,
|
||||||
@@ -671,7 +625,6 @@ export default function page() {
|
|||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
offline,
|
offline,
|
||||||
isPlayingIntro,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const volumeUpCb = useCallback(async () => {
|
const volumeUpCb = useCallback(async () => {
|
||||||
@@ -1023,6 +976,7 @@ export default function page() {
|
|||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
nowPlayingMetadata={nowPlayingMetadata}
|
||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
onPlaybackStateChange={onPlaybackStateChanged}
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
onLoad={() => setIsVideoLoaded(true)}
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
@@ -1084,9 +1038,6 @@ 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,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -53,6 +54,9 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
({ item, itemWithSources }) => {
|
({ item, itemWithSources }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
|
const { getDownloadedItemById } = useDownload();
|
||||||
|
const downloadedItem =
|
||||||
|
isOffline && item.Id ? getDownloadedItemById(item.Id) : null;
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -91,17 +95,29 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
|
|
||||||
// Needs to automatically change the selected to the default values for default indexes.
|
// Needs to automatically change the selected to the default values for default indexes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// When offline, use the indices stored in userData (the last-used tracks for this file)
|
||||||
|
// rather than the server's defaults, so MediaSourceButton reflects what will actually play.
|
||||||
|
const offlineUserData = downloadedItem?.userData;
|
||||||
|
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource ?? undefined,
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex:
|
||||||
audioIndex: defaultAudioIndex,
|
offlineUserData && !offlineUserData.isTranscoded
|
||||||
|
? offlineUserData.subtitleStreamIndex
|
||||||
|
: (defaultSubtitleIndex ?? -1),
|
||||||
|
audioIndex:
|
||||||
|
offlineUserData && !offlineUserData.isTranscoded
|
||||||
|
? offlineUserData.audioStreamIndex
|
||||||
|
: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
}, [
|
}, [
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
|
downloadedItem?.userData?.audioStreamIndex,
|
||||||
|
downloadedItem?.userData?.subtitleStreamIndex,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -232,14 +248,12 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
colors={itemColors}
|
colors={itemColors}
|
||||||
/>
|
/>
|
||||||
<View className='w-1' />
|
<View className='w-1' />
|
||||||
{!isOffline && (
|
<MediaSourceButton
|
||||||
<MediaSourceButton
|
selectedOptions={selectedOptions}
|
||||||
selectedOptions={selectedOptions}
|
setSelectedOptions={setSelectedOptions}
|
||||||
setSelectedOptions={setSelectedOptions}
|
item={itemWithSources}
|
||||||
item={itemWithSources}
|
colors={itemColors}
|
||||||
colors={itemColors}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { BITRATES } from "./BitRateSheet";
|
import { BITRATES } from "./BitRateSheet";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
@@ -28,6 +30,14 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const isOffline = useOfflineMode();
|
||||||
|
const { getDownloadedItemById } = useDownload();
|
||||||
|
|
||||||
|
// For transcoded downloads there's only one burned-in track — nothing to pick
|
||||||
|
const isTranscodedDownload = useMemo(() => {
|
||||||
|
if (!isOffline || !item?.Id) return false;
|
||||||
|
return getDownloadedItemById(item.Id)?.userData?.isTranscoded === true;
|
||||||
|
}, [isOffline, item?.Id, getDownloadedItemById]);
|
||||||
|
|
||||||
const effectiveColors = colors || {
|
const effectiveColors = colors || {
|
||||||
primary: "#7c3aed",
|
primary: "#7c3aed",
|
||||||
@@ -72,34 +82,36 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
const optionGroups: OptionGroup[] = useMemo(() => {
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||||
const groups: OptionGroup[] = [];
|
const groups: OptionGroup[] = [];
|
||||||
|
|
||||||
// Bitrate group
|
if (!isOffline) {
|
||||||
groups.push({
|
// Bitrate group
|
||||||
title: t("item_card.quality"),
|
|
||||||
options: BITRATES.map((bitrate) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: bitrate.key,
|
|
||||||
value: bitrate,
|
|
||||||
selected: bitrate.value === selectedOptions.bitrate?.value,
|
|
||||||
onPress: () =>
|
|
||||||
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Media Source group (only if multiple sources)
|
|
||||||
if (item?.MediaSources && item.MediaSources.length > 1) {
|
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("item_card.video"),
|
title: t("item_card.quality"),
|
||||||
options: item.MediaSources.map((source) => ({
|
options: BITRATES.map((bitrate) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: getMediaSourceDisplayName(source),
|
label: bitrate.key,
|
||||||
value: source,
|
value: bitrate,
|
||||||
selected: source.Id === selectedOptions.mediaSource?.Id,
|
selected: bitrate.value === selectedOptions.bitrate?.value,
|
||||||
onPress: () =>
|
onPress: () =>
|
||||||
setSelectedOptions(
|
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
||||||
(prev) => prev && { ...prev, mediaSource: source },
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Media Source group (only if multiple sources)
|
||||||
|
if (item?.MediaSources && item.MediaSources.length > 1) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.video"),
|
||||||
|
options: item.MediaSources.map((source) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: getMediaSourceDisplayName(source),
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selectedOptions.mediaSource?.Id,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, mediaSource: source },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio track group
|
// Audio track group
|
||||||
@@ -150,6 +162,7 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
return groups;
|
return groups;
|
||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
|
isOffline,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
audioStreams,
|
audioStreams,
|
||||||
subtitleStreams,
|
subtitleStreams,
|
||||||
@@ -178,6 +191,8 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isTranscodedDownload) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
|
|||||||
@@ -96,14 +96,23 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||||
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
offline: isOffline ? "true" : "false",
|
offline: isOffline ? "true" : "false",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (selectedOptions.audioIndex !== undefined) {
|
||||||
|
queryParams.set("audioIndex", selectedOptions.audioIndex.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedOptions.subtitleIndex !== undefined) {
|
||||||
|
queryParams.set(
|
||||||
|
"subtitleIndex",
|
||||||
|
selectedOptions.subtitleIndex.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -292,6 +301,29 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const buildOfflineQueryParams = useCallback(
|
||||||
|
(downloadedItem: NonNullable<ReturnType<typeof getDownloadedItemById>>) => {
|
||||||
|
const isTranscoded = downloadedItem.userData?.isTranscoded === true;
|
||||||
|
const audioIdx = isTranscoded
|
||||||
|
? downloadedItem.userData?.audioStreamIndex
|
||||||
|
: selectedOptions.audioIndex;
|
||||||
|
const subtitleIdx = isTranscoded
|
||||||
|
? downloadedItem.userData?.subtitleStreamIndex
|
||||||
|
: selectedOptions.subtitleIndex;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
if (audioIdx !== undefined) params.set("audioIndex", audioIdx.toString());
|
||||||
|
if (subtitleIdx !== undefined)
|
||||||
|
params.set("subtitleIndex", subtitleIdx.toString());
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
[item, selectedOptions],
|
||||||
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
@@ -302,13 +334,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
// If already in offline mode, play downloaded file directly
|
// If already in offline mode, play downloaded file directly
|
||||||
if (isOffline && downloadedItem) {
|
if (isOffline && downloadedItem) {
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer(buildOfflineQueryParams(downloadedItem).toString());
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,13 +357,9 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
hideModal();
|
hideModal();
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer(
|
||||||
itemId: item.Id!,
|
buildOfflineQueryParams(downloadedItem).toString(),
|
||||||
offline: "true",
|
);
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
}}
|
}}
|
||||||
color='purple'
|
color='purple'
|
||||||
>
|
>
|
||||||
@@ -374,13 +396,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
{
|
{
|
||||||
text: t("player.downloaded_file_yes"),
|
text: t("player.downloaded_file_yes"),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer(buildOfflineQueryParams(downloadedItem).toString());
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
},
|
},
|
||||||
isPreferred: true,
|
isPreferred: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -16,7 +15,6 @@ import { ListGroup } from "../list/ListGroup";
|
|||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const PlaybackControlsSettings: React.FC = () => {
|
export const PlaybackControlsSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -250,15 +248,6 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</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>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ interface BottomControlsProps {
|
|||||||
currentTime: number;
|
currentTime: number;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
showSkipButton: boolean;
|
showSkipButton: boolean;
|
||||||
skipButtonText: string;
|
|
||||||
showSkipCreditButton: boolean;
|
showSkipCreditButton: boolean;
|
||||||
skipCreditButtonText: string;
|
|
||||||
hasContentAfterCredits: boolean;
|
hasContentAfterCredits: boolean;
|
||||||
skipIntro: () => void;
|
skipIntro: () => void;
|
||||||
skipCredit: () => void;
|
skipCredit: () => void;
|
||||||
@@ -59,11 +57,6 @@ 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> = ({
|
||||||
@@ -74,9 +67,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
showSkipButton,
|
showSkipButton,
|
||||||
skipButtonText,
|
|
||||||
showSkipCreditButton,
|
showSkipCreditButton,
|
||||||
skipCreditButtonText,
|
|
||||||
hasContentAfterCredits,
|
hasContentAfterCredits,
|
||||||
skipIntro,
|
skipIntro,
|
||||||
skipCredit,
|
skipCredit,
|
||||||
@@ -96,9 +87,6 @@ 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();
|
||||||
@@ -145,18 +133,10 @@ 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}
|
||||||
buttonText={skipButtonText}
|
buttonText='Skip Intro'
|
||||||
/>
|
/>
|
||||||
{/* Smart Skip Credits behavior:
|
{/* Smart Skip Credits behavior:
|
||||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||||
@@ -166,7 +146,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||||
}
|
}
|
||||||
onPress={skipCredit}
|
onPress={skipCredit}
|
||||||
buttonText={skipCreditButtonText}
|
buttonText='Skip Credits'
|
||||||
/>
|
/>
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
|
|||||||
@@ -4,15 +4,7 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import {
|
import { type FC, useCallback, useEffect, useState } from "react";
|
||||||
type FC,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -24,17 +16,17 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { useSegments } from "@/utils/segments";
|
import { ticksToMs } from "@/utils/time";
|
||||||
import { msToSeconds, ticksToMs } from "@/utils/time";
|
|
||||||
import { BottomControls } from "./BottomControls";
|
import { BottomControls } from "./BottomControls";
|
||||||
import { CenterControls } from "./CenterControls";
|
import { CenterControls } from "./CenterControls";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
@@ -50,9 +42,6 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
|||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||||
|
|
||||||
// No-op function to avoid creating new references on every render
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -83,10 +72,6 @@ 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> = ({
|
||||||
@@ -116,9 +101,6 @@ 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();
|
||||||
@@ -128,18 +110,6 @@ export const Controls: FC<Props> = ({
|
|||||||
const [episodeView, setEpisodeView] = useState(false);
|
const [episodeView, setEpisodeView] = useState(false);
|
||||||
const [showAudioSlider, setShowAudioSlider] = 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 { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||||
const { previousItem, nextItem } = usePlaybackManager({
|
const { previousItem, nextItem } = usePlaybackManager({
|
||||||
item,
|
item,
|
||||||
@@ -330,122 +300,27 @@ export const Controls: FC<Props> = ({
|
|||||||
subtitleIndex: string;
|
subtitleIndex: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Fetch all segments for the current item
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
const { data: segments } = useSegments(
|
item.Id!,
|
||||||
item.Id ?? "",
|
currentTime,
|
||||||
|
seek,
|
||||||
|
play,
|
||||||
offline,
|
offline,
|
||||||
downloadedFiles,
|
|
||||||
api,
|
api,
|
||||||
|
downloadedFiles,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert milliseconds to seconds for segment comparison
|
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||||
const currentTimeSeconds = msToSeconds(currentTime);
|
useCreditSkipper(
|
||||||
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
item.Id!,
|
||||||
|
currentTime,
|
||||||
// Wrapper to convert segment skip from seconds to milliseconds
|
seek,
|
||||||
// Includes 200ms delay to allow seek operation to complete before resuming playback
|
play,
|
||||||
const seekMs = useCallback(
|
offline,
|
||||||
(timeInSeconds: number) => {
|
api,
|
||||||
// Cancel any pending play call to avoid race conditions
|
downloadedFiles,
|
||||||
if (playTimeoutRef.current) {
|
maxMs,
|
||||||
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(
|
const goToItemCommon = useCallback(
|
||||||
(item: BaseItemDto) => {
|
(item: BaseItemDto) => {
|
||||||
@@ -659,9 +534,7 @@ export const Controls: FC<Props> = ({
|
|||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
remainingTime={remainingTime}
|
remainingTime={remainingTime}
|
||||||
showSkipButton={showSkipButton}
|
showSkipButton={showSkipButton}
|
||||||
skipButtonText={skipButtonText}
|
|
||||||
showSkipCreditButton={showSkipCreditButton}
|
showSkipCreditButton={showSkipCreditButton}
|
||||||
skipCreditButtonText={skipCreditButtonText}
|
|
||||||
hasContentAfterCredits={hasContentAfterCredits}
|
hasContentAfterCredits={hasContentAfterCredits}
|
||||||
skipIntro={skipIntro}
|
skipIntro={skipIntro}
|
||||||
skipCredit={skipCredit}
|
skipCredit={skipCredit}
|
||||||
@@ -681,9 +554,6 @@ 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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
@@ -15,12 +16,27 @@ export const useDownloadedFileOpener = () => {
|
|||||||
console.error("Attempted to open a file without an ID.");
|
console.error("Attempted to open a file without an ID.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const downloadedItem = getDownloadedItemById(item.Id);
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id,
|
itemId: item.Id,
|
||||||
offline: "true",
|
offline: "true",
|
||||||
playbackPosition:
|
playbackPosition:
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (downloadedItem?.userData?.audioStreamIndex !== undefined) {
|
||||||
|
queryParams.set(
|
||||||
|
"audioIndex",
|
||||||
|
downloadedItem.userData.audioStreamIndex.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (downloadedItem?.userData?.subtitleStreamIndex !== undefined) {
|
||||||
|
queryParams.set(
|
||||||
|
"subtitleIndex",
|
||||||
|
downloadedItem.userData.subtitleStreamIndex.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -186,6 +186,20 @@ export const usePlaybackManager = ({
|
|||||||
: playedPercentage,
|
: playedPercentage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Sync selected audio/subtitle tracks so next playback resumes with
|
||||||
|
// the same tracks the user had active — but only for non-transcoded
|
||||||
|
// downloads where the user can freely switch tracks.
|
||||||
|
userData: localItem.userData.isTranscoded
|
||||||
|
? localItem.userData
|
||||||
|
: {
|
||||||
|
...localItem.userData,
|
||||||
|
audioStreamIndex:
|
||||||
|
playbackProgressInfo.AudioStreamIndex ??
|
||||||
|
localItem.userData.audioStreamIndex,
|
||||||
|
subtitleStreamIndex:
|
||||||
|
playbackProgressInfo.SubtitleStreamIndex ??
|
||||||
|
localItem.userData.subtitleStreamIndex,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
// Force invalidate queries so they refetch from updated local database
|
// Force invalidate queries so they refetch from updated local database
|
||||||
queryClient.invalidateQueries({ queryKey: ["item", itemId] });
|
queryClient.invalidateQueries({ queryKey: ["item", itemId] });
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -43,6 +43,12 @@ class MpvPlayerModule : Module() {
|
|||||||
view.loadVideo(config)
|
view.loadVideo(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now Playing metadata for media controls (iOS-only, no-op on Android)
|
||||||
|
// Android handles media session differently via MediaSessionCompat
|
||||||
|
Prop("nowPlayingMetadata") { _: MpvPlayerView, _: Map<String, String>? ->
|
||||||
|
// No-op on Android - media session integration would require MediaSessionCompat
|
||||||
|
}
|
||||||
|
|
||||||
// Async function to play video
|
// Async function to play video
|
||||||
AsyncFunction("play") { view: MpvPlayerView ->
|
AsyncFunction("play") { view: MpvPlayerView ->
|
||||||
view.play()
|
view.play()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ protocol MPVLayerRendererDelegate: AnyObject {
|
|||||||
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
|
||||||
|
func renderer(_ renderer: MPVLayerRenderer, didSelectAudioOutput audioOutput: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MPV player using vo_avfoundation for video output.
|
/// MPV player using vo_avfoundation for video output.
|
||||||
@@ -347,7 +348,8 @@ final class MPVLayerRenderer {
|
|||||||
("pause", MPV_FORMAT_FLAG),
|
("pause", MPV_FORMAT_FLAG),
|
||||||
("track-list/count", MPV_FORMAT_INT64),
|
("track-list/count", MPV_FORMAT_INT64),
|
||||||
("paused-for-cache", MPV_FORMAT_FLAG),
|
("paused-for-cache", MPV_FORMAT_FLAG),
|
||||||
("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
("demuxer-cache-duration", MPV_FORMAT_DOUBLE),
|
||||||
|
("current-ao", MPV_FORMAT_STRING)
|
||||||
]
|
]
|
||||||
for (name, format) in properties {
|
for (name, format) in properties {
|
||||||
mpv_observe_property(handle, 0, name, format)
|
mpv_observe_property(handle, 0, name, format)
|
||||||
@@ -552,6 +554,15 @@ final class MPVLayerRenderer {
|
|||||||
self.delegate?.renderer(self, didBecomeTracksReady: true)
|
self.delegate?.renderer(self, didBecomeTracksReady: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "current-ao":
|
||||||
|
// Audio output is now active - notify delegate
|
||||||
|
if let aoName = getStringProperty(handle: handle, name: name) {
|
||||||
|
print("[MPV] 🔊 Audio output selected: \(aoName)")
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.renderer(self, didSelectAudioOutput: aoName)
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
188
modules/mpv-player/ios/MPVNowPlayingManager.swift
Normal file
188
modules/mpv-player/ios/MPVNowPlayingManager.swift
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaPlayer
|
||||||
|
import UIKit
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// Simple manager for Now Playing info and remote commands.
|
||||||
|
/// Stores all state internally and updates Now Playing when ready.
|
||||||
|
class MPVNowPlayingManager {
|
||||||
|
static let shared = MPVNowPlayingManager()
|
||||||
|
|
||||||
|
// State
|
||||||
|
private var title: String?
|
||||||
|
private var artist: String?
|
||||||
|
private var albumTitle: String?
|
||||||
|
private var cachedArtwork: MPMediaItemArtwork?
|
||||||
|
private var duration: TimeInterval = 0
|
||||||
|
private var position: TimeInterval = 0
|
||||||
|
private var isPlaying: Bool = false
|
||||||
|
private var isCommandsSetup = false
|
||||||
|
|
||||||
|
private var artworkTask: URLSessionDataTask?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Audio Session
|
||||||
|
|
||||||
|
func activateAudioSession() {
|
||||||
|
do {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(.playback, mode: .moviePlayback)
|
||||||
|
try session.setActive(true)
|
||||||
|
print("[NowPlaying] Audio session activated")
|
||||||
|
} catch {
|
||||||
|
print("[NowPlaying] Audio session error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivateAudioSession() {
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
print("[NowPlaying] Audio session deactivated")
|
||||||
|
} catch {
|
||||||
|
print("[NowPlaying] Deactivation error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Remote Commands
|
||||||
|
|
||||||
|
func setupRemoteCommands(
|
||||||
|
playHandler: @escaping () -> Void,
|
||||||
|
pauseHandler: @escaping () -> Void,
|
||||||
|
toggleHandler: @escaping () -> Void,
|
||||||
|
seekHandler: @escaping (TimeInterval) -> Void,
|
||||||
|
skipForward: @escaping (TimeInterval) -> Void,
|
||||||
|
skipBackward: @escaping (TimeInterval) -> Void
|
||||||
|
) {
|
||||||
|
guard !isCommandsSetup else { return }
|
||||||
|
isCommandsSetup = true
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
let cc = MPRemoteCommandCenter.shared()
|
||||||
|
|
||||||
|
cc.playCommand.isEnabled = true
|
||||||
|
cc.playCommand.addTarget { _ in playHandler(); return .success }
|
||||||
|
|
||||||
|
cc.pauseCommand.isEnabled = true
|
||||||
|
cc.pauseCommand.addTarget { _ in pauseHandler(); return .success }
|
||||||
|
|
||||||
|
cc.togglePlayPauseCommand.isEnabled = true
|
||||||
|
cc.togglePlayPauseCommand.addTarget { _ in toggleHandler(); return .success }
|
||||||
|
|
||||||
|
cc.skipForwardCommand.isEnabled = true
|
||||||
|
cc.skipForwardCommand.preferredIntervals = [15]
|
||||||
|
cc.skipForwardCommand.addTarget { e in
|
||||||
|
if let ev = e as? MPSkipIntervalCommandEvent { skipForward(ev.interval) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
cc.skipBackwardCommand.isEnabled = true
|
||||||
|
cc.skipBackwardCommand.preferredIntervals = [15]
|
||||||
|
cc.skipBackwardCommand.addTarget { e in
|
||||||
|
if let ev = e as? MPSkipIntervalCommandEvent { skipBackward(ev.interval) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
cc.changePlaybackPositionCommand.isEnabled = true
|
||||||
|
cc.changePlaybackPositionCommand.addTarget { e in
|
||||||
|
if let ev = e as? MPChangePlaybackPositionCommandEvent { seekHandler(ev.positionTime) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[NowPlaying] Remote commands ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupRemoteCommands() {
|
||||||
|
guard isCommandsSetup else { return }
|
||||||
|
|
||||||
|
let cc = MPRemoteCommandCenter.shared()
|
||||||
|
cc.playCommand.removeTarget(nil)
|
||||||
|
cc.pauseCommand.removeTarget(nil)
|
||||||
|
cc.togglePlayPauseCommand.removeTarget(nil)
|
||||||
|
cc.skipForwardCommand.removeTarget(nil)
|
||||||
|
cc.skipBackwardCommand.removeTarget(nil)
|
||||||
|
cc.changePlaybackPositionCommand.removeTarget(nil)
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
UIApplication.shared.endReceivingRemoteControlEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
isCommandsSetup = false
|
||||||
|
print("[NowPlaying] Remote commands cleaned up")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State Updates (call these whenever data changes)
|
||||||
|
|
||||||
|
/// Set metadata (title, artist, artwork URL)
|
||||||
|
func setMetadata(title: String?, artist: String?, albumTitle: String?, artworkUrl: String?) {
|
||||||
|
self.title = title
|
||||||
|
self.artist = artist
|
||||||
|
self.albumTitle = albumTitle
|
||||||
|
|
||||||
|
print("[NowPlaying] Metadata: \(title ?? "nil")")
|
||||||
|
|
||||||
|
// Load artwork async
|
||||||
|
artworkTask?.cancel()
|
||||||
|
if let urlString = artworkUrl, let url = URL(string: urlString) {
|
||||||
|
artworkTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||||
|
if let data = data, let image = UIImage(data: data) {
|
||||||
|
self?.cachedArtwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||||
|
print("[NowPlaying] Artwork loaded")
|
||||||
|
DispatchQueue.main.async { self?.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artworkTask?.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update playback state (position, duration, playing)
|
||||||
|
func updatePlayback(position: TimeInterval, duration: TimeInterval, isPlaying: Bool) {
|
||||||
|
self.position = position
|
||||||
|
self.duration = duration
|
||||||
|
self.isPlaying = isPlaying
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear everything
|
||||||
|
func clear() {
|
||||||
|
artworkTask?.cancel()
|
||||||
|
title = nil
|
||||||
|
artist = nil
|
||||||
|
albumTitle = nil
|
||||||
|
cachedArtwork = nil
|
||||||
|
duration = 0
|
||||||
|
position = 0
|
||||||
|
isPlaying = false
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||||
|
print("[NowPlaying] Cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
/// Refresh Now Playing info if we have enough data
|
||||||
|
private func refresh() {
|
||||||
|
guard duration > 0 else {
|
||||||
|
print("[NowPlaying] refresh skipped - duration is 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var info: [String: Any] = [
|
||||||
|
MPMediaItemPropertyPlaybackDuration: duration,
|
||||||
|
MPNowPlayingInfoPropertyElapsedPlaybackTime: position,
|
||||||
|
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0
|
||||||
|
]
|
||||||
|
|
||||||
|
if let title { info[MPMediaItemPropertyTitle] = title }
|
||||||
|
if let artist { info[MPMediaItemPropertyArtist] = artist }
|
||||||
|
if let albumTitle { info[MPMediaItemPropertyAlbumTitle] = albumTitle }
|
||||||
|
if let cachedArtwork { info[MPMediaItemPropertyArtwork] = cachedArtwork }
|
||||||
|
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||||
|
print("[NowPlaying] ✅ Set info: title=\(title ?? "nil"), dur=\(Int(duration))s, pos=\(Int(position))s, rate=\(isPlaying ? 1.0 : 0.0)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,21 @@ public class MpvPlayerModule: Module {
|
|||||||
view.loadVideo(config: config)
|
view.loadVideo(config: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now Playing metadata for iOS Control Center and Lock Screen
|
||||||
|
Prop("nowPlayingMetadata") { (view: MpvPlayerView, metadata: [String: Any]?) in
|
||||||
|
guard let metadata = metadata else { return }
|
||||||
|
// Convert Any values to String, filtering out nil/null values
|
||||||
|
var stringMetadata: [String: String] = [:]
|
||||||
|
for (key, value) in metadata {
|
||||||
|
if let stringValue = value as? String {
|
||||||
|
stringMetadata[key] = stringValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !stringMetadata.isEmpty {
|
||||||
|
view.setNowPlayingMetadata(stringMetadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Async function to play video
|
// Async function to play video
|
||||||
AsyncFunction("play") { (view: MpvPlayerView) in
|
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||||
view.play()
|
view.play()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import CoreMedia
|
import CoreMedia
|
||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
|
import MediaPlayer
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// Configuration for loading a video
|
/// Configuration for loading a video
|
||||||
@@ -41,7 +42,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
private var renderer: MPVLayerRenderer?
|
private var renderer: MPVLayerRenderer?
|
||||||
private var videoContainer: UIView!
|
private var videoContainer: UIView!
|
||||||
private var pipController: PiPController?
|
private var pipController: PiPController?
|
||||||
|
|
||||||
let onLoad = EventDispatcher()
|
let onLoad = EventDispatcher()
|
||||||
let onPlaybackStateChange = EventDispatcher()
|
let onPlaybackStateChange = EventDispatcher()
|
||||||
let onProgress = EventDispatcher()
|
let onProgress = EventDispatcher()
|
||||||
@@ -54,10 +54,13 @@ class MpvPlayerView: ExpoView {
|
|||||||
private var intendedPlayState: Bool = false
|
private var intendedPlayState: Bool = false
|
||||||
private var _isZoomedToFill: Bool = false
|
private var _isZoomedToFill: Bool = false
|
||||||
|
|
||||||
|
// Reference to now playing manager
|
||||||
|
private let nowPlayingManager = MPVNowPlayingManager.shared
|
||||||
|
|
||||||
required init(appContext: AppContext? = nil) {
|
required init(appContext: AppContext? = nil) {
|
||||||
super.init(appContext: appContext)
|
super.init(appContext: appContext)
|
||||||
|
setupNotifications()
|
||||||
setupView()
|
setupView()
|
||||||
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupView() {
|
private func setupView() {
|
||||||
@@ -109,6 +112,77 @@ class MpvPlayerView: ExpoView {
|
|||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Session & Notifications
|
||||||
|
|
||||||
|
private func setupNotifications() {
|
||||||
|
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(handleAudioSessionInterruption),
|
||||||
|
name: AVAudioSession.interruptionNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
||||||
|
guard let userInfo = notification.userInfo,
|
||||||
|
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .began:
|
||||||
|
// Interruption began - pause the video
|
||||||
|
print("[MPV] Audio session interrupted - pausing video")
|
||||||
|
self.pause()
|
||||||
|
|
||||||
|
case .ended:
|
||||||
|
// Interruption ended - check if we should resume
|
||||||
|
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
|
||||||
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||||
|
if options.contains(.shouldResume) {
|
||||||
|
print("[MPV] Audio session interruption ended - can resume")
|
||||||
|
// Don't auto-resume - let user manually resume playback
|
||||||
|
} else {
|
||||||
|
print("[MPV] Audio session interruption ended - should not resume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupRemoteCommands() {
|
||||||
|
nowPlayingManager.setupRemoteCommands(
|
||||||
|
playHandler: { [weak self] in self?.play() },
|
||||||
|
pauseHandler: { [weak self] in self?.pause() },
|
||||||
|
toggleHandler: { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if self.intendedPlayState { self.pause() } else { self.play() }
|
||||||
|
},
|
||||||
|
seekHandler: { [weak self] time in self?.seekTo(position: time) },
|
||||||
|
skipForward: { [weak self] interval in self?.seekBy(offset: interval) },
|
||||||
|
skipBackward: { [weak self] interval in self?.seekBy(offset: -interval) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Now Playing Info
|
||||||
|
|
||||||
|
func setNowPlayingMetadata(_ metadata: [String: String]) {
|
||||||
|
print("[MPV] setNowPlayingMetadata: \(metadata["title"] ?? "nil")")
|
||||||
|
nowPlayingManager.setMetadata(
|
||||||
|
title: metadata["title"],
|
||||||
|
artist: metadata["artist"],
|
||||||
|
albumTitle: metadata["albumTitle"],
|
||||||
|
artworkUrl: metadata["artworkUri"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearNowPlayingInfo() {
|
||||||
|
nowPlayingManager.cleanupRemoteCommands()
|
||||||
|
nowPlayingManager.deactivateAudioSession()
|
||||||
|
nowPlayingManager.clear()
|
||||||
|
}
|
||||||
|
|
||||||
func loadVideo(config: VideoLoadConfig) {
|
func loadVideo(config: VideoLoadConfig) {
|
||||||
// Skip reload if same URL is already playing
|
// Skip reload if same URL is already playing
|
||||||
if currentURL == config.url {
|
if currentURL == config.url {
|
||||||
@@ -149,6 +223,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
intendedPlayState = true
|
intendedPlayState = true
|
||||||
|
setupRemoteCommands()
|
||||||
renderer?.play()
|
renderer?.play()
|
||||||
pipController?.setPlaybackRate(1.0)
|
pipController?.setPlaybackRate(1.0)
|
||||||
pipController?.updatePlaybackState()
|
pipController?.updatePlaybackState()
|
||||||
@@ -162,10 +237,17 @@ class MpvPlayerView: ExpoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func seekTo(position: Double) {
|
func seekTo(position: Double) {
|
||||||
|
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||||
|
cachedPosition = position
|
||||||
|
syncNowPlaying(isPlaying: !isPaused())
|
||||||
renderer?.seek(to: position)
|
renderer?.seek(to: position)
|
||||||
}
|
}
|
||||||
|
|
||||||
func seekBy(offset: Double) {
|
func seekBy(offset: Double) {
|
||||||
|
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||||
|
let newPosition = max(0, min(cachedPosition + offset, cachedDuration))
|
||||||
|
cachedPosition = newPosition
|
||||||
|
syncNowPlaying(isPlaying: !isPaused())
|
||||||
renderer?.seek(by: offset)
|
renderer?.seek(by: offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,19 +374,28 @@ class MpvPlayerView: ExpoView {
|
|||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
displayLayer.removeFromSuperlayer()
|
displayLayer.removeFromSuperlayer()
|
||||||
|
clearNowPlayingInfo()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - MPVLayerRendererDelegate
|
// MARK: - MPVLayerRendererDelegate
|
||||||
|
|
||||||
extension MpvPlayerView: MPVLayerRendererDelegate {
|
extension MpvPlayerView: MPVLayerRendererDelegate {
|
||||||
|
|
||||||
|
// MARK: - Single location for Now Playing updates
|
||||||
|
private func syncNowPlaying(isPlaying: Bool) {
|
||||||
|
print("[MPV] syncNowPlaying: pos=\(Int(cachedPosition))s, dur=\(Int(cachedDuration))s, playing=\(isPlaying)")
|
||||||
|
nowPlayingManager.updatePlayback(position: cachedPosition, duration: cachedDuration, isPlaying: isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) {
|
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) {
|
||||||
cachedPosition = position
|
cachedPosition = position
|
||||||
cachedDuration = duration
|
cachedDuration = duration
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// Update PiP current time for progress bar
|
|
||||||
if self.pipController?.isPictureInPictureActive == true {
|
if self.pipController?.isPictureInPictureActive == true {
|
||||||
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
|
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
|
||||||
}
|
}
|
||||||
@@ -321,12 +412,10 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
|||||||
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
|
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// Don't update intendedPlayState here - it's only set by user actions (play/pause)
|
|
||||||
// This prevents PiP UI flicker during seeking
|
|
||||||
|
|
||||||
// Sync timebase rate with actual playback state
|
print("[MPV] didChangePause: isPaused=\(isPaused), cachedDuration=\(self.cachedDuration)")
|
||||||
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
|
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
|
||||||
|
self.syncNowPlaying(isPlaying: !isPaused)
|
||||||
self.onPlaybackStateChange([
|
self.onPlaybackStateChange([
|
||||||
"isPaused": isPaused,
|
"isPaused": isPaused,
|
||||||
"isPlaying": !isPaused,
|
"isPlaying": !isPaused,
|
||||||
@@ -358,6 +447,13 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
|||||||
self.onTracksReady([:])
|
self.onTracksReady([:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
||||||
|
// Audio output is now active - this is the right time to activate audio session and set Now Playing
|
||||||
|
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
|
||||||
|
nowPlayingManager.activateAudioSession()
|
||||||
|
syncNowPlaying(isPlaying: !isPaused())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PiPControllerDelegate
|
// MARK: - PiPControllerDelegate
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ export type OnErrorEventPayload = {
|
|||||||
|
|
||||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||||
|
|
||||||
|
export type NowPlayingMetadata = {
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
albumTitle?: string;
|
||||||
|
artworkUri?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type MpvPlayerModuleEvents = {
|
export type MpvPlayerModuleEvents = {
|
||||||
onChange: (params: ChangeEventPayload) => void;
|
onChange: (params: ChangeEventPayload) => void;
|
||||||
};
|
};
|
||||||
@@ -48,6 +55,8 @@ export type VideoSource = {
|
|||||||
export type MpvPlayerViewProps = {
|
export type MpvPlayerViewProps = {
|
||||||
source?: VideoSource;
|
source?: VideoSource;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
|
/** Metadata for iOS Control Center and Lock Screen now playing info */
|
||||||
|
nowPlayingMetadata?: NowPlayingMetadata;
|
||||||
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
|
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
|
||||||
onPlaybackStateChange?: (event: {
|
onPlaybackStateChange?: (event: {
|
||||||
nativeEvent: OnPlaybackStateChangePayload;
|
nativeEvent: OnPlaybackStateChangePayload;
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export interface MediaTimeSegment {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Segment {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||||
export interface DownloadedItem {
|
export interface DownloadedItem {
|
||||||
/** The Jellyfin item DTO. */
|
/** The Jellyfin item DTO. */
|
||||||
@@ -50,12 +56,6 @@ export interface DownloadedItem {
|
|||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
/** The credit segments for the item. */
|
/** The credit segments for the item. */
|
||||||
creditSegments?: MediaTimeSegment[];
|
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. */
|
/** The user data for the item. */
|
||||||
userData: UserData;
|
userData: UserData;
|
||||||
}
|
}
|
||||||
@@ -144,12 +144,6 @@ export type JobStatus = {
|
|||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
||||||
creditSegments?: MediaTimeSegment[];
|
creditSegments?: MediaTimeSegment[];
|
||||||
/** 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 */
|
/** The audio stream index selected for this download */
|
||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
/** The subtitle stream index selected for this download */
|
/** The subtitle stream index selected for this download */
|
||||||
|
|||||||
@@ -24,31 +24,6 @@
|
|||||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
"player": {
|
|
||||||
"skip_intro": "Skip Intro",
|
|
||||||
"skip_outro": "Skip Outro",
|
|
||||||
"skip_recap": "Skip Recap",
|
|
||||||
"skip_commercial": "Skip Commercial",
|
|
||||||
"skip_preview": "Skip Preview",
|
|
||||||
"error": "Error",
|
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
|
||||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
|
||||||
"client_error": "Client Error",
|
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
|
||||||
"message_from_server": "Message from Server: {{message}}",
|
|
||||||
"next_episode": "Next Episode",
|
|
||||||
"refresh_tracks": "Refresh Tracks",
|
|
||||||
"audio_tracks": "Audio Tracks:",
|
|
||||||
"playback_state": "Playback State:",
|
|
||||||
"index": "Index:",
|
|
||||||
"continue_watching": "Continue Watching",
|
|
||||||
"go_back": "Go Back",
|
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
|
||||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
|
||||||
"downloaded_file_yes": "Yes",
|
|
||||||
"downloaded_file_no": "No",
|
|
||||||
"downloaded_file_cancel": "Cancel"
|
|
||||||
},
|
|
||||||
"server": {
|
"server": {
|
||||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||||
"server_url_placeholder": "http(s)://your-server.com",
|
"server_url_placeholder": "http(s)://your-server.com",
|
||||||
@@ -333,21 +308,6 @@
|
|||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
"auto_play_next_episode": "Auto-play Next Episode",
|
"auto_play_next_episode": "Auto-play Next Episode",
|
||||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
"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"
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
@@ -630,6 +590,26 @@
|
|||||||
"custom_links": {
|
"custom_links": {
|
||||||
"no_links": "No Links"
|
"no_links": "No Links"
|
||||||
},
|
},
|
||||||
|
"player": {
|
||||||
|
"error": "Error",
|
||||||
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
|
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
|
"client_error": "Client Error",
|
||||||
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
|
"message_from_server": "Message from Server: {{message}}",
|
||||||
|
"next_episode": "Next Episode",
|
||||||
|
"refresh_tracks": "Refresh Tracks",
|
||||||
|
"audio_tracks": "Audio Tracks:",
|
||||||
|
"playback_state": "Playback State:",
|
||||||
|
"index": "Index:",
|
||||||
|
"continue_watching": "Continue Watching",
|
||||||
|
"go_back": "Go Back",
|
||||||
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
|
"downloaded_file_yes": "Yes",
|
||||||
|
"downloaded_file_no": "No",
|
||||||
|
"downloaded_file_cancel": "Cancel"
|
||||||
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Next Up",
|
"next_up": "Next Up",
|
||||||
"no_items_to_display": "No Items to Display",
|
"no_items_to_display": "No Items to Display",
|
||||||
|
|||||||
@@ -134,9 +134,6 @@ export enum VideoPlayer {
|
|||||||
MPV = 0,
|
MPV = 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Segment skip behavior options
|
|
||||||
export type SegmentSkipMode = "none" | "ask" | "auto";
|
|
||||||
|
|
||||||
// Audio transcoding mode - controls how surround audio is handled
|
// Audio transcoding mode - controls how surround audio is handled
|
||||||
// This controls server-side transcoding behavior for audio streams.
|
// This controls server-side transcoding behavior for audio streams.
|
||||||
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
||||||
@@ -184,12 +181,6 @@ export type Settings = {
|
|||||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||||
autoPlayEpisodeCount: number;
|
autoPlayEpisodeCount: number;
|
||||||
autoPlayNextEpisode: boolean;
|
autoPlayNextEpisode: boolean;
|
||||||
// Media segment skip preferences
|
|
||||||
skipIntro: SegmentSkipMode;
|
|
||||||
skipOutro: SegmentSkipMode;
|
|
||||||
skipRecap: SegmentSkipMode;
|
|
||||||
skipCommercial: SegmentSkipMode;
|
|
||||||
skipPreview: SegmentSkipMode;
|
|
||||||
// Playback speed settings
|
// Playback speed settings
|
||||||
defaultPlaybackSpeed: number;
|
defaultPlaybackSpeed: number;
|
||||||
playbackSpeedPerMedia: Record<string, number>;
|
playbackSpeedPerMedia: Record<string, number>;
|
||||||
@@ -275,12 +266,6 @@ export const defaultValues: Settings = {
|
|||||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||||
autoPlayEpisodeCount: 0,
|
autoPlayEpisodeCount: 0,
|
||||||
autoPlayNextEpisode: true,
|
autoPlayNextEpisode: true,
|
||||||
// Media segment skip defaults
|
|
||||||
skipIntro: "ask",
|
|
||||||
skipOutro: "ask",
|
|
||||||
skipRecap: "ask",
|
|
||||||
skipCommercial: "ask",
|
|
||||||
skipPreview: "ask",
|
|
||||||
// Playback speed defaults
|
// Playback speed defaults
|
||||||
defaultPlaybackSpeed: 1.0,
|
defaultPlaybackSpeed: 1.0,
|
||||||
playbackSpeedPerMedia: {},
|
playbackSpeedPerMedia: {},
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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,16 +74,10 @@ export const getSegmentsForItem = (
|
|||||||
): {
|
): {
|
||||||
introSegments: MediaTimeSegment[];
|
introSegments: MediaTimeSegment[];
|
||||||
creditSegments: MediaTimeSegment[];
|
creditSegments: MediaTimeSegment[];
|
||||||
recapSegments: MediaTimeSegment[];
|
|
||||||
commercialSegments: MediaTimeSegment[];
|
|
||||||
previewSegments: MediaTimeSegment[];
|
|
||||||
} => {
|
} => {
|
||||||
return {
|
return {
|
||||||
introSegments: item.introSegments || [],
|
introSegments: item.introSegments || [],
|
||||||
creditSegments: item.creditSegments || [],
|
creditSegments: item.creditSegments || [],
|
||||||
recapSegments: item.recapSegments || [],
|
|
||||||
commercialSegments: item.commercialSegments || [],
|
|
||||||
previewSegments: item.previewSegments || [],
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,9 +95,6 @@ const fetchMediaSegments = async (
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
introSegments: MediaTimeSegment[];
|
introSegments: MediaTimeSegment[];
|
||||||
creditSegments: MediaTimeSegment[];
|
creditSegments: MediaTimeSegment[];
|
||||||
recapSegments: MediaTimeSegment[];
|
|
||||||
commercialSegments: MediaTimeSegment[];
|
|
||||||
previewSegments: MediaTimeSegment[];
|
|
||||||
} | null> => {
|
} | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
||||||
@@ -111,22 +102,13 @@ const fetchMediaSegments = async (
|
|||||||
{
|
{
|
||||||
headers: getAuthHeaders(api),
|
headers: getAuthHeaders(api),
|
||||||
params: {
|
params: {
|
||||||
includeSegmentTypes: [
|
includeSegmentTypes: ["Intro", "Outro"],
|
||||||
"Intro",
|
|
||||||
"Outro",
|
|
||||||
"Recap",
|
|
||||||
"Commercial",
|
|
||||||
"Preview",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const introSegments: MediaTimeSegment[] = [];
|
const introSegments: MediaTimeSegment[] = [];
|
||||||
const creditSegments: MediaTimeSegment[] = [];
|
const creditSegments: MediaTimeSegment[] = [];
|
||||||
const recapSegments: MediaTimeSegment[] = [];
|
|
||||||
const commercialSegments: MediaTimeSegment[] = [];
|
|
||||||
const previewSegments: MediaTimeSegment[] = [];
|
|
||||||
|
|
||||||
response.data.Items.forEach((segment) => {
|
response.data.Items.forEach((segment) => {
|
||||||
const timeSegment: MediaTimeSegment = {
|
const timeSegment: MediaTimeSegment = {
|
||||||
@@ -142,27 +124,13 @@ const fetchMediaSegments = async (
|
|||||||
case "Outro":
|
case "Outro":
|
||||||
creditSegments.push(timeSegment);
|
creditSegments.push(timeSegment);
|
||||||
break;
|
break;
|
||||||
case "Recap":
|
// Optionally handle other types like Recap, Commercial, Preview
|
||||||
recapSegments.push(timeSegment);
|
|
||||||
break;
|
|
||||||
case "Commercial":
|
|
||||||
commercialSegments.push(timeSegment);
|
|
||||||
break;
|
|
||||||
case "Preview":
|
|
||||||
previewSegments.push(timeSegment);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { introSegments, creditSegments };
|
||||||
introSegments,
|
|
||||||
creditSegments,
|
|
||||||
recapSegments,
|
|
||||||
commercialSegments,
|
|
||||||
previewSegments,
|
|
||||||
};
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Return null to indicate we should try legacy endpoints
|
// Return null to indicate we should try legacy endpoints
|
||||||
return null;
|
return null;
|
||||||
@@ -178,9 +146,6 @@ const fetchLegacySegments = async (
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
introSegments: MediaTimeSegment[];
|
introSegments: MediaTimeSegment[];
|
||||||
creditSegments: MediaTimeSegment[];
|
creditSegments: MediaTimeSegment[];
|
||||||
recapSegments: MediaTimeSegment[];
|
|
||||||
commercialSegments: MediaTimeSegment[];
|
|
||||||
previewSegments: MediaTimeSegment[];
|
|
||||||
}> => {
|
}> => {
|
||||||
const introSegments: MediaTimeSegment[] = [];
|
const introSegments: MediaTimeSegment[] = [];
|
||||||
const creditSegments: MediaTimeSegment[] = [];
|
const creditSegments: MediaTimeSegment[] = [];
|
||||||
@@ -219,13 +184,7 @@ const fetchLegacySegments = async (
|
|||||||
console.error("Failed to fetch legacy segments", error);
|
console.error("Failed to fetch legacy segments", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { introSegments, creditSegments };
|
||||||
introSegments,
|
|
||||||
creditSegments,
|
|
||||||
recapSegments: [],
|
|
||||||
commercialSegments: [],
|
|
||||||
previewSegments: [],
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAndParseSegments = async (
|
export const fetchAndParseSegments = async (
|
||||||
@@ -234,9 +193,6 @@ export const fetchAndParseSegments = async (
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
introSegments: MediaTimeSegment[];
|
introSegments: MediaTimeSegment[];
|
||||||
creditSegments: MediaTimeSegment[];
|
creditSegments: MediaTimeSegment[];
|
||||||
recapSegments: MediaTimeSegment[];
|
|
||||||
commercialSegments: MediaTimeSegment[];
|
|
||||||
previewSegments: MediaTimeSegment[];
|
|
||||||
}> => {
|
}> => {
|
||||||
// Try new API first (Jellyfin 10.11+)
|
// Try new API first (Jellyfin 10.11+)
|
||||||
const newSegments = await fetchMediaSegments(itemId, api);
|
const newSegments = await fetchMediaSegments(itemId, api);
|
||||||
|
|||||||
Reference in New Issue
Block a user