Files
streamyfin/utils/segments.ts
Gauvain e85fc77643 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.
2026-05-27 23:20:41 +02:00

168 lines
4.9 KiB
TypeScript

import { Api } from "@jellyfin/sdk";
import { MediaSegmentType } from "@jellyfin/sdk/lib/generated-client/models/media-segment-type";
import { getMediaSegmentsApi } from "@jellyfin/sdk/lib/utils/api/media-segments-api";
import { useQuery } from "@tanstack/react-query";
import React from "react";
import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types";
import { getAuthHeaders } from "./jellyfin/jellyfin";
export interface SegmentBuckets {
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
recapSegments: MediaTimeSegment[];
commercialSegments: MediaTimeSegment[];
previewSegments: MediaTimeSegment[];
}
// Legacy endpoints (intro-skipper / chapter-credits plugins on pre-10.11 servers)
interface IntroTimestamps {
IntroStart: number;
IntroEnd: number;
Valid: boolean;
}
interface CreditTimestamps {
Credits: { Start: number; End: number; Valid: boolean };
}
const TICKS_PER_SECOND = 10_000_000;
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
const emptyBuckets = (): SegmentBuckets => ({
introSegments: [],
creditSegments: [],
recapSegments: [],
commercialSegments: [],
previewSegments: [],
});
export const useSegments = (
itemId: string,
isOffline: boolean,
downloadedFiles: DownloadedItem[] | undefined,
api: Api | null,
) => {
const downloadedItem = React.useMemo(
() => downloadedFiles?.find((d) => d.item.Id === itemId),
[downloadedFiles, itemId],
);
return useQuery({
queryKey: ["segments", itemId, isOffline],
queryFn: async () => {
if (isOffline && downloadedItem) {
return getSegmentsForItem(downloadedItem);
}
if (!api) {
throw new Error("API client is not available");
}
return fetchAndParseSegments(itemId, api);
},
enabled: !!itemId && (isOffline ? !!downloadedItem : !!api),
});
};
export const getSegmentsForItem = (item: DownloadedItem): SegmentBuckets => ({
introSegments: item.introSegments || [],
creditSegments: item.creditSegments || [],
recapSegments: item.recapSegments || [],
commercialSegments: item.commercialSegments || [],
previewSegments: item.previewSegments || [],
});
/** Jellyfin 10.11+ unified MediaSegments API. Returns null so the caller can fall back. */
const fetchMediaSegments = async (
itemId: string,
api: Api,
): Promise<SegmentBuckets | null> => {
try {
const response = await getMediaSegmentsApi(api).getItemSegments({
itemId,
includeSegmentTypes: [
MediaSegmentType.Intro,
MediaSegmentType.Outro,
MediaSegmentType.Recap,
MediaSegmentType.Commercial,
MediaSegmentType.Preview,
],
});
const buckets = emptyBuckets();
for (const segment of response.data.Items ?? []) {
if (segment.StartTicks == null || segment.EndTicks == null) continue;
const timeSegment: MediaTimeSegment = {
startTime: ticksToSeconds(segment.StartTicks),
endTime: ticksToSeconds(segment.EndTicks),
text: segment.Type ?? "",
};
switch (segment.Type) {
case MediaSegmentType.Intro:
buckets.introSegments.push(timeSegment);
break;
case MediaSegmentType.Outro:
buckets.creditSegments.push(timeSegment);
break;
case MediaSegmentType.Recap:
buckets.recapSegments.push(timeSegment);
break;
case MediaSegmentType.Commercial:
buckets.commercialSegments.push(timeSegment);
break;
case MediaSegmentType.Preview:
buckets.previewSegments.push(timeSegment);
break;
}
}
return buckets;
} catch {
return null;
}
};
/** Pre-10.11 fallback: third-party intro-skipper / chapter-credits plugin endpoints. */
const fetchLegacySegments = async (
itemId: string,
api: Api,
): Promise<SegmentBuckets> => {
const buckets = emptyBuckets();
const [introRes, creditRes] = await Promise.allSettled([
api.axiosInstance.get<IntroTimestamps>(
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
{ headers: getAuthHeaders(api) },
),
api.axiosInstance.get<CreditTimestamps>(
`${api.basePath}/Episode/${itemId}/Timestamps`,
{ headers: getAuthHeaders(api) },
),
]);
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
buckets.introSegments.push({
startTime: introRes.value.data.IntroStart,
endTime: introRes.value.data.IntroEnd,
text: "Intro",
});
}
if (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) {
buckets.creditSegments.push({
startTime: creditRes.value.data.Credits.Start,
endTime: creditRes.value.data.Credits.End,
text: "Outro",
});
}
return buckets;
};
export const fetchAndParseSegments = async (
itemId: string,
api: Api,
): Promise<SegmentBuckets> => {
const newSegments = await fetchMediaSegments(itemId, api);
return newSegments ?? fetchLegacySegments(itemId, api);
};