mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-28 01:28:27 +01:00
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.
168 lines
4.9 KiB
TypeScript
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);
|
|
};
|