mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-28 09:38:25 +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:
@@ -1,46 +1,40 @@
|
||||
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";
|
||||
|
||||
// New Jellyfin 10.11+ Media Segments API types
|
||||
interface MediaSegmentDto {
|
||||
Id: string;
|
||||
ItemId: string;
|
||||
Type: "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
||||
StartTicks: number;
|
||||
EndTicks: number;
|
||||
export interface SegmentBuckets {
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
recapSegments: MediaTimeSegment[];
|
||||
commercialSegments: MediaTimeSegment[];
|
||||
previewSegments: MediaTimeSegment[];
|
||||
}
|
||||
|
||||
interface MediaSegmentsResponse {
|
||||
Items: MediaSegmentDto[];
|
||||
}
|
||||
|
||||
// Legacy API types (for fallback)
|
||||
// Legacy endpoints (intro-skipper / chapter-credits plugins on pre-10.11 servers)
|
||||
interface IntroTimestamps {
|
||||
EpisodeId: string;
|
||||
HideSkipPromptAt: number;
|
||||
IntroEnd: number;
|
||||
IntroStart: number;
|
||||
ShowSkipPromptAt: number;
|
||||
IntroEnd: number;
|
||||
Valid: boolean;
|
||||
}
|
||||
|
||||
interface CreditTimestamps {
|
||||
Introduction: {
|
||||
Start: number;
|
||||
End: number;
|
||||
Valid: boolean;
|
||||
};
|
||||
Credits: {
|
||||
Start: number;
|
||||
End: number;
|
||||
Valid: boolean;
|
||||
};
|
||||
Credits: { Start: number; End: number; Valid: boolean };
|
||||
}
|
||||
|
||||
const TICKS_PER_SECOND = 10000000;
|
||||
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,
|
||||
@@ -48,7 +42,6 @@ export const useSegments = (
|
||||
downloadedFiles: DownloadedItem[] | undefined,
|
||||
api: Api | null,
|
||||
) => {
|
||||
// Memoize the lookup so the array is only traversed when dependencies change
|
||||
const downloadedItem = React.useMemo(
|
||||
() => downloadedFiles?.find((d) => d.item.Id === itemId),
|
||||
[downloadedFiles, itemId],
|
||||
@@ -65,141 +58,110 @@ export const useSegments = (
|
||||
}
|
||||
return fetchAndParseSegments(itemId, api);
|
||||
},
|
||||
enabled: isOffline ? !!downloadedItem : !!api,
|
||||
enabled: !!itemId && (isOffline ? !!downloadedItem : !!api),
|
||||
});
|
||||
};
|
||||
|
||||
export const getSegmentsForItem = (
|
||||
item: DownloadedItem,
|
||||
): {
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
} => {
|
||||
return {
|
||||
introSegments: item.introSegments || [],
|
||||
creditSegments: item.creditSegments || [],
|
||||
};
|
||||
};
|
||||
export const getSegmentsForItem = (item: DownloadedItem): SegmentBuckets => ({
|
||||
introSegments: item.introSegments || [],
|
||||
creditSegments: item.creditSegments || [],
|
||||
recapSegments: item.recapSegments || [],
|
||||
commercialSegments: item.commercialSegments || [],
|
||||
previewSegments: item.previewSegments || [],
|
||||
});
|
||||
|
||||
/**
|
||||
* Converts Jellyfin ticks to seconds
|
||||
*/
|
||||
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
|
||||
|
||||
/**
|
||||
* Fetches segments using the new Jellyfin 10.11+ MediaSegments API
|
||||
*/
|
||||
/** Jellyfin 10.11+ unified MediaSegments API. Returns null so the caller can fall back. */
|
||||
const fetchMediaSegments = async (
|
||||
itemId: string,
|
||||
api: Api,
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
} | null> => {
|
||||
): Promise<SegmentBuckets | null> => {
|
||||
try {
|
||||
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
||||
`${api.basePath}/MediaSegments/${itemId}`,
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
params: {
|
||||
includeSegmentTypes: ["Intro", "Outro"],
|
||||
},
|
||||
},
|
||||
);
|
||||
const response = await getMediaSegmentsApi(api).getItemSegments({
|
||||
itemId,
|
||||
includeSegmentTypes: [
|
||||
MediaSegmentType.Intro,
|
||||
MediaSegmentType.Outro,
|
||||
MediaSegmentType.Recap,
|
||||
MediaSegmentType.Commercial,
|
||||
MediaSegmentType.Preview,
|
||||
],
|
||||
});
|
||||
|
||||
const introSegments: MediaTimeSegment[] = [];
|
||||
const creditSegments: MediaTimeSegment[] = [];
|
||||
|
||||
response.data.Items.forEach((segment) => {
|
||||
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,
|
||||
text: segment.Type ?? "",
|
||||
};
|
||||
|
||||
switch (segment.Type) {
|
||||
case "Intro":
|
||||
introSegments.push(timeSegment);
|
||||
case MediaSegmentType.Intro:
|
||||
buckets.introSegments.push(timeSegment);
|
||||
break;
|
||||
case "Outro":
|
||||
creditSegments.push(timeSegment);
|
||||
case MediaSegmentType.Outro:
|
||||
buckets.creditSegments.push(timeSegment);
|
||||
break;
|
||||
// Optionally handle other types like Recap, Commercial, Preview
|
||||
default:
|
||||
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 { introSegments, creditSegments };
|
||||
} catch (_error) {
|
||||
// Return null to indicate we should try legacy endpoints
|
||||
return buckets;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches segments using legacy pre-10.11 endpoints
|
||||
*/
|
||||
/** Pre-10.11 fallback: third-party intro-skipper / chapter-credits plugin endpoints. */
|
||||
const fetchLegacySegments = async (
|
||||
itemId: string,
|
||||
api: Api,
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
}> => {
|
||||
const introSegments: MediaTimeSegment[] = [];
|
||||
const creditSegments: MediaTimeSegment[] = [];
|
||||
): Promise<SegmentBuckets> => {
|
||||
const buckets = emptyBuckets();
|
||||
|
||||
try {
|
||||
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) },
|
||||
),
|
||||
]);
|
||||
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) {
|
||||
introSegments.push({
|
||||
startTime: introRes.value.data.IntroStart,
|
||||
endTime: introRes.value.data.IntroEnd,
|
||||
text: "Intro",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
creditRes.status === "fulfilled" &&
|
||||
creditRes.value.data.Credits.Valid
|
||||
) {
|
||||
creditSegments.push({
|
||||
startTime: creditRes.value.data.Credits.Start,
|
||||
endTime: creditRes.value.data.Credits.End,
|
||||
text: "Credits",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch legacy segments", error);
|
||||
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
||||
buckets.introSegments.push({
|
||||
startTime: introRes.value.data.IntroStart,
|
||||
endTime: introRes.value.data.IntroEnd,
|
||||
text: "Intro",
|
||||
});
|
||||
}
|
||||
|
||||
return { introSegments, creditSegments };
|
||||
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<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
}> => {
|
||||
// Try new API first (Jellyfin 10.11+)
|
||||
): Promise<SegmentBuckets> => {
|
||||
const newSegments = await fetchMediaSegments(itemId, api);
|
||||
if (newSegments) {
|
||||
return newSegments;
|
||||
}
|
||||
|
||||
// Fallback to legacy endpoints
|
||||
return fetchLegacySegments(itemId, api);
|
||||
return newSegments ?? fetchLegacySegments(itemId, api);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user