Finish skip segments integration

This commit is contained in:
Pas 2026-01-19 17:37:40 -07:00
parent 4af6a72894
commit 170f92bd79
7 changed files with 196 additions and 24 deletions

View file

@ -8,6 +8,22 @@
<lastBuildDate>Mon, 29 Sep 2025 18:00:00 MST</lastBuildDate>
<atom:link href="https://pstream.mov/notifications.xml" rel="self" type="application/rss+xml" />
<item>
<guid>notification-058</guid>
<title>New TheIntroDB Skip Segments feature!</title>
<description>You can now quickly leave feedback, correct, and submit skip times for movies and TV shows using your TheIntroDB API key right within P-Stream!
If you haven't made an account on TheIntroDB yet, you can do so below! Generate your API key and add it to your P-Stream connectionssettings!
Then, the next time you encounter a skip button, you can leave feedback and correct the skip time if it's incorrect!
And if an episode does't have a segment yet, you can submit it yourself from the new Skip Segments menu on the player!
</description>
<link>https://theintrodb.org/sign-up</link>
<pubDate>Mon, 19 Jan 2026 17:40:00 MST</pubDate>
<category>feature</category>
</item>
<item>
<guid>notification-057</guid>
<title>P-Stream v5.3.4 released!</title>

View file

@ -978,6 +978,16 @@
"intro": "Skip Intro",
"recap": "Skip Recap",
"credits": "Skip Credits",
"skipSegments": "Skip Segments",
"submitSegment": "Submit Segment",
"noSegments": "No skip segments available",
"startsAt": "Starts at {{time}}",
"endsAt": "Ends at {{time}}",
"timeUnknown": "Time unknown",
"confidence": "confidence",
"submissions": "{{count}} submissions",
"connectApiKeyMessage": "To submit new segments, enter your TheIntroDB API key in the connections settings",
"endOfVideo": "End",
"feedback": {
"title": "Was this skip correct?",
"modal": {

View file

@ -64,17 +64,17 @@ function parseTimeToSeconds(timeStr: string): number | null {
return parsed;
}
interface SubmissionFormProps {
interface TIDBSubmissionFormProps {
segment: SegmentData;
onSuccess?: () => void;
onCancel?: () => void;
}
export function SubmissionForm({
export function TIDBSubmissionForm({
segment,
onSuccess,
onCancel,
}: SubmissionFormProps) {
}: TIDBSubmissionFormProps) {
const { t } = useTranslation();
const meta = usePlayerStore((s) => s.meta);
const tidbKey = usePreferencesStore((s) => s.tidbKey);
@ -336,15 +336,20 @@ export function SubmissionForm({
>
{t("player.skipTime.feedback.modal.cancel")}
</Button>
<button
type="submit"
<Button
theme="purple"
disabled={isSubmitting}
className="bg-buttons-purple hover:bg-buttons-purpleHover disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 py-2 rounded font-medium transition-colors pointer-events-auto"
loading={isSubmitting}
onClick={() => {
// Trigger form submission
const form = document.querySelector("form");
if (form) form.requestSubmit();
}}
>
{isSubmitting
? t("player.skipTime.feedback.modal.submitting")
: t("player.skipTime.feedback.modal.submit")}
</button>
</Button>
</div>
</form>
</div>

View file

@ -23,6 +23,7 @@ import { LanguageSubtitlesView } from "./settings/LanguageSubtitlesView";
import { PlaybackSettingsView } from "./settings/PlaybackSettingsView";
import { QualityView } from "./settings/QualityView";
import { SettingsMenu } from "./settings/SettingsMenu";
import { SkipSegmentsView } from "./settings/SkipSegmentsView";
import { TranscriptView } from "./settings/TranscriptView";
import { TranslateSubtitleView } from "./settings/TranslateSubtitleView";
import { WatchPartyView } from "./settings/WatchPartyView";
@ -49,20 +50,20 @@ function SettingsOverlay({ id }: { id: string }) {
return (
<Overlay id={id}>
<OverlayRouter id={id}>
<OverlayPage id={id} path="/" width={343} height={452}>
<OverlayPage id={id} path="/" width={343} height={496}>
<SettingsMenu id={id} />
</OverlayPage>
<OverlayPage id={id} path="/quality" width={343} height={452}>
<OverlayPage id={id} path="/quality" width={343} height={496}>
<Menu.Card>
<QualityView id={id} />
</Menu.Card>
</OverlayPage>
<OverlayPage id={id} path="/audio" width={343} height={452}>
<OverlayPage id={id} path="/audio" width={343} height={496}>
<Menu.Card>
<AudioView id={id} />
</Menu.Card>
</OverlayPage>
<OverlayPage id={id} path="/captions" width={343} height={452}>
<OverlayPage id={id} path="/captions" width={343} height={496}>
<Menu.CardWithScrollable>
<CaptionsView
id={id}
@ -72,7 +73,7 @@ function SettingsOverlay({ id }: { id: string }) {
</Menu.CardWithScrollable>
</OverlayPage>
{/* This is used by the captions shortcut in bottomControls of player */}
<OverlayPage id={id} path="/captionsOverlay" width={343} height={452}>
<OverlayPage id={id} path="/captionsOverlay" width={343} height={496}>
<Menu.CardWithScrollable>
<CaptionsView id={id} onChooseLanguage={setChosenLanguage} />
</Menu.CardWithScrollable>
@ -81,7 +82,7 @@ function SettingsOverlay({ id }: { id: string }) {
id={id}
path="/captionsOverlay/languagesOverlay"
width={443}
height={452}
height={496}
>
<Menu.CardWithScrollable>
{chosenLanguage && (
@ -98,7 +99,7 @@ function SettingsOverlay({ id }: { id: string }) {
id={id}
path="/captionsOverlay/languagesOverlay/translateSubtitleOverlay"
width={443}
height={452}
height={496}
>
<Menu.CardWithScrollable>
{captionToTranslate && (
@ -110,7 +111,7 @@ function SettingsOverlay({ id }: { id: string }) {
)}
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage id={id} path="/captions/settings" width={343} height={452}>
<OverlayPage id={id} path="/captions/settings" width={343} height={496}>
<Menu.Card>
<CaptionSettingsView id={id} />
</Menu.Card>
@ -120,18 +121,18 @@ function SettingsOverlay({ id }: { id: string }) {
id={id}
path="/captions/settingsOverlay"
width={343}
height={452}
height={496}
>
<Menu.Card>
<CaptionSettingsView id={id} overlayBackLink />
</Menu.Card>
</OverlayPage>
<OverlayPage id={id} path="/source" width={343} height={452}>
<OverlayPage id={id} path="/source" width={343} height={496}>
<Menu.CardWithScrollable>
<SourceSelectionView id={id} onChoose={setChosenSourceId} />
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage id={id} path="/source/embeds" width={343} height={452}>
<OverlayPage id={id} path="/source/embeds" width={343} height={496}>
<Menu.CardWithScrollable>
<EmbedSelectionView id={id} sourceId={chosenSourceId} />
</Menu.CardWithScrollable>
@ -141,11 +142,21 @@ function SettingsOverlay({ id }: { id: string }) {
<PlaybackSettingsView id={id} />
</Menu.Card>
</OverlayPage>
<OverlayPage
id={id}
path="/playback/skip-segments"
width={343}
height={496}
>
<Menu.Card>
<SkipSegmentsView id={id} />
</Menu.Card>
</OverlayPage>
<OverlayPage
id={id}
path="/captions/transcript"
width={343}
height={452}
height={496}
>
<Menu.CardWithScrollable>
<TranscriptView id={id} />
@ -155,7 +166,7 @@ function SettingsOverlay({ id }: { id: string }) {
id={id}
path="/captions/languages"
width={443}
height={452}
height={496}
>
<Menu.CardWithScrollable>
{chosenLanguage && (
@ -171,7 +182,7 @@ function SettingsOverlay({ id }: { id: string }) {
id={id}
path="/captions/languages/translateSubtitleOverlay"
width={443}
height={452}
height={496}
>
<Menu.CardWithScrollable>
{captionToTranslate && (
@ -180,7 +191,7 @@ function SettingsOverlay({ id }: { id: string }) {
</Menu.CardWithScrollable>
</OverlayPage>
<DownloadRoutes id={id} />
<OverlayPage id={id} path="/watchparty" width={343} height={455}>
<OverlayPage id={id} path="/watchparty" width={343} height={496}>
<Menu.CardWithScrollable>
<WatchPartyView id={id} />
</Menu.CardWithScrollable>

View file

@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
import { SegmentData } from "@/components/player/hooks/useSkipTime";
import { SubmissionForm } from "@/components/player/TIDBSubmissionForm";
import { TIDBSubmissionForm } from "@/components/player/TIDBSubmissionForm";
import { Transition } from "@/components/utils/Transition";
import { useOverlayStack } from "@/stores/interface/overlayStack";
import { usePlayerStore } from "@/stores/player/store";
@ -153,7 +153,7 @@ export function ThumbsFeedback({
</div>
{showSubmissionModal && feedbackData && (
<SubmissionForm
<TIDBSubmissionForm
segment={feedbackData.segment}
onSuccess={handleSubmissionSuccess}
onCancel={handleSubmissionCancel}

View file

@ -151,6 +151,11 @@ export function SettingsMenu({ id }: { id: string }) {
<Menu.ChevronLink onClick={() => router.navigate("/playback")}>
{t("player.menus.settings.playbackItem")}
</Menu.ChevronLink>
<Menu.ChevronLink
onClick={() => router.navigate("/playback/skip-segments")}
>
{t("player.skipTime.skipSegments")}
</Menu.ChevronLink>
</Menu.Section>
</Menu.Card>
);

View file

@ -0,0 +1,125 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { useSkipTime } from "@/components/player/hooks/useSkipTime";
import { Menu } from "@/components/player/internals/ContextMenu";
import { TIDBSubmissionForm } from "@/components/player/TIDBSubmissionForm";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences";
import { formatSeconds } from "@/utils/formatSeconds";
export function SkipSegmentsView({ id }: { id: string }) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const display = usePlayerStore((s) => s.display);
const segments = useSkipTime();
const tidbKey = usePreferencesStore((s) => s.tidbKey);
const [showSubmissionForm, setShowSubmissionForm] = useState(false);
const handleSeek = (seconds: number) => {
display?.setTime(seconds);
};
const getSegmentTypeLabel = (type: "intro" | "recap" | "credits") => {
switch (type) {
case "intro":
return t("player.skipTime.intro");
case "recap":
return t("player.skipTime.recap");
case "credits":
return t("player.skipTime.credits");
default:
return type;
}
};
return (
<>
<Menu.BackLink onClick={() => router.navigate("/")}>
{t("player.skipTime.skipSegments")}
</Menu.BackLink>
<Menu.Section>
<div className="flex gap-2 mb-4">
{tidbKey ? (
<Button
theme="purple"
className="flex-1"
onClick={() => setShowSubmissionForm(true)}
>
{t("player.skipTime.submitSegment")}
</Button>
) : (
<div className="flex-1 text-center text-sm text-type-secondary p-3 bg-video-context-light bg-opacity-10 rounded-lg">
{t("player.skipTime.connectApiKeyMessage")}
</div>
)}
</div>
<div className="space-y-2">
{segments.length === 0 ? (
<div className="text-center py-4 text-type-secondary">
{t("player.skipTime.noSegments")}
</div>
) : (
segments.map((segment) => {
// Handle start time (null means 0/start of video)
const startTime = (segment.start_ms ?? 0) / 1000;
// Handle end time (null means end of video)
const endTime = segment.end_ms ? segment.end_ms / 1000 : null;
return (
<button
key={`${segment.type}-${segment.submission_count}-${segment.start_ms || "null"}`}
type="button"
onClick={() => handleSeek(startTime)}
className="w-full text-left p-3 rounded-xl bg-video-context-light bg-opacity-10 hover:bg-opacity-20 transition-colors cursor-pointer"
>
<div className="flex items-center justify-between">
<span className="text-video-context-type-main font-medium">
{getSegmentTypeLabel(segment.type)}
</span>
<span className="text-video-context-type-secondary text-sm">
{segment.start_ms === null
? "0:00"
: formatSeconds(startTime)}{" "}
-{" "}
{endTime === null
? t("player.skipTime.endOfVideo")
: formatSeconds(endTime)}
</span>
</div>
<div className="text-xs text-type-secondary mt-1">
{segment.submission_count} submissions
{segment.confidence !== null && (
<span className="ml-2">
({Math.round(segment.confidence * 100)}% confidence)
</span>
)}
</div>
</button>
);
})
)}
</div>
</Menu.Section>
{showSubmissionForm && tidbKey && (
<TIDBSubmissionForm
segment={{
type: "intro",
start_ms: null,
end_ms: null,
confidence: null,
submission_count: 0,
}}
onSuccess={() => {
setShowSubmissionForm(false);
// Optionally refresh segments here
}}
onCancel={() => setShowSubmissionForm(false)}
/>
)}
</>
);
}