mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-15 02:10:23 +01:00
feat(player): add media segment skip with all 5 Jellyfin segment types
Closes #1312 Fixes #883 Adds a unified segment skip feature using the Jellyfin 10.11+ MediaSegments API. Replaces the legacy intro-only and credits-only hooks with a single useSegmentSkipper hook covering Intro, Outro, Recap, Commercial, and Preview. Three modes per segment type: none, ask (show button), auto (skip automatically). A dedicated submenu under Playback Controls keeps the main settings page uncluttered. Highlights: - utils/segments.ts uses getMediaSegmentsApi from @jellyfin/sdk so includeSegmentTypes is serialized as repeated keys instead of the bracket-encoded form axios produces by default (the Jellyfin server silently ignored the filter otherwise). Falls back to the pre-10.11 intro-skipper / chapter-credits plugin endpoints when the new API is unavailable. - hooks/useSegmentSkipper.ts stores seek and haptic in refs so the auto-skip effect does not re-run when their identities change (useHaptic returns a fresh no-op every render when disabled). currentSegment is memoized; the per-segment-type setting lookup uses a small map instead of a switch IIFE. - components/video-player/controls/Controls.tsx prioritizes Commercial > Recap > Intro > Preview > Outro when multiple segments overlap and exposes the active type to BottomControls via skipButtonText. - components/video-player/controls/BottomControls.tsx accepts the dynamic skipButtonText/skipCreditButtonText props. - providers/Downloads/types.ts extends DownloadedItem with the three new segment buckets for offline playback. - utils/atoms/settings.ts adds SegmentSkipMode and the five skip settings, defaulting to "ask". - app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx renders the five dropdowns from a data table. - translations/en.json and translations/fr.json add the new keys.
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
||||
InactivityTimeout,
|
||||
type MpvCacheMode,
|
||||
type MpvVoDriver,
|
||||
type SegmentSkipMode,
|
||||
TVTypographyScale,
|
||||
useSettings,
|
||||
} from "@/utils/atoms/settings";
|
||||
@@ -47,6 +48,22 @@ import {
|
||||
} from "@/utils/secureCredentials";
|
||||
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
||||
|
||||
const SEGMENT_SKIP_ROWS: {
|
||||
key:
|
||||
| "skipIntro"
|
||||
| "skipOutro"
|
||||
| "skipRecap"
|
||||
| "skipCommercial"
|
||||
| "skipPreview";
|
||||
labelKey: string;
|
||||
}[] = [
|
||||
{ key: "skipIntro", labelKey: "skip_intro" },
|
||||
{ key: "skipOutro", labelKey: "skip_outro" },
|
||||
{ key: "skipRecap", labelKey: "skip_recap" },
|
||||
{ key: "skipCommercial", labelKey: "skip_commercial" },
|
||||
{ key: "skipPreview", labelKey: "skip_preview" },
|
||||
];
|
||||
|
||||
export default function SettingsTV() {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -535,6 +552,30 @@ export default function SettingsTV() {
|
||||
);
|
||||
}, [inactivityTimeoutOptions, t]);
|
||||
|
||||
// Segment skip: same auto/ask/none choice for every segment type.
|
||||
const segmentSkipModeLabel = (mode: SegmentSkipMode) =>
|
||||
t(`home.settings.other.segment_skip_${mode}`);
|
||||
|
||||
const buildSegmentSkipOptions = (
|
||||
current: SegmentSkipMode,
|
||||
): TVOptionItem<SegmentSkipMode>[] => [
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_auto"),
|
||||
value: "auto",
|
||||
selected: current === "auto",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_ask"),
|
||||
value: "ask",
|
||||
selected: current === "ask",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_none"),
|
||||
value: "none",
|
||||
selected: current === "none",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
@@ -819,6 +860,30 @@ export default function SettingsTV() {
|
||||
formatValue={(v) => `${v} MB`}
|
||||
/>
|
||||
|
||||
{/* Segment Skip Section */}
|
||||
<TVSectionHeader
|
||||
title={t("home.settings.other.segment_skip_settings")}
|
||||
/>
|
||||
{SEGMENT_SKIP_ROWS.map((row, index) => {
|
||||
const current = (settings[row.key] ?? "ask") as SegmentSkipMode;
|
||||
const rowLabel = t(`home.settings.other.${row.labelKey}`);
|
||||
return (
|
||||
<TVSettingsOptionButton
|
||||
key={row.key}
|
||||
label={rowLabel}
|
||||
value={segmentSkipModeLabel(current)}
|
||||
isFirst={index === 0}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: rowLabel,
|
||||
options: buildSegmentSkipOptions(current),
|
||||
onSelect: (value) => updateSettings({ [row.key]: value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Appearance Section */}
|
||||
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
||||
<TVSettingsOptionButton
|
||||
|
||||
101
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
101
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
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 { SegmentSkipMode, useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
type SkipSettingKey =
|
||||
| "skipIntro"
|
||||
| "skipOutro"
|
||||
| "skipRecap"
|
||||
| "skipCommercial"
|
||||
| "skipPreview";
|
||||
|
||||
const SEGMENTS: ReadonlyArray<{ key: SkipSettingKey; labelKey: string }> = [
|
||||
{ key: "skipIntro", labelKey: "skip_intro" },
|
||||
{ key: "skipOutro", labelKey: "skip_outro" },
|
||||
{ key: "skipRecap", labelKey: "skip_recap" },
|
||||
{ key: "skipCommercial", labelKey: "skip_commercial" },
|
||||
{ key: "skipPreview", labelKey: "skip_preview" },
|
||||
];
|
||||
|
||||
const SEGMENT_SKIP_OPTIONS = (
|
||||
t: TFunction<"translation", undefined>,
|
||||
): Array<{ label: string; value: SegmentSkipMode }> => [
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
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 options = useMemo(() => SEGMENT_SKIP_OPTIONS(t), [t]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<View className='px-4'>
|
||||
<ListGroup>
|
||||
{SEGMENTS.map(({ key, labelKey }) => {
|
||||
const current = settings[key];
|
||||
const locked = pluginSettings?.[key]?.locked ?? false;
|
||||
const groups = [
|
||||
{
|
||||
options: options.map((o) => ({
|
||||
type: "radio" as const,
|
||||
label: o.label,
|
||||
value: o.value,
|
||||
selected: o.value === current,
|
||||
disabled: locked,
|
||||
onPress: () => {
|
||||
if (locked) return;
|
||||
updateSettings({ [key]: o.value });
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<ListItem
|
||||
key={key}
|
||||
title={t(`home.settings.other.${labelKey}`)}
|
||||
subtitle={t(`home.settings.other.${labelKey}_description`)}
|
||||
disabled={locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={groups}
|
||||
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_${current}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t(`home.settings.other.${labelKey}`)}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user