mirror of
https://github.com/p-stream/p-stream.git
synced 2026-08-14 19:34:51 +00:00
Finish skip segments integration
This commit is contained in:
parent
4af6a72894
commit
170f92bd79
7 changed files with 196 additions and 24 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
125
src/components/player/atoms/settings/SkipSegmentsView.tsx
Normal file
125
src/components/player/atoms/settings/SkipSegmentsView.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue