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:
Gauvain
2026-05-27 22:56:39 +02:00
parent 149e3b1b17
commit cec2c4a712
14 changed files with 774 additions and 382 deletions

View File

@@ -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

View 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>
);
}