pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

update skip button to support other segments

Pas c0029577 0b253648

+304 -147
+5
src/assets/locales/en.json
··· 968 968 "remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}", 969 969 "shortRegular": "{{timeWatched}}", 970 970 "shortRemaining": "-{{timeLeft}}" 971 + }, 972 + "skipTime": { 973 + "intro": "Skip Intro", 974 + "recap": "Skip Recap", 975 + "credits": "Skip Credits" 971 976 } 972 977 }, 973 978 "support": {
-123
src/components/player/atoms/SkipIntroButton.tsx
··· 1 - import classNames from "classnames"; 2 - import { useCallback } from "react"; 3 - 4 - import { Icon, Icons } from "@/components/Icon"; 5 - import { useSkipTracking } from "@/components/player/hooks/useSkipTracking"; 6 - import { Transition } from "@/components/utils/Transition"; 7 - import { usePlayerStore } from "@/stores/player/store"; 8 - 9 - function shouldShowSkipButton( 10 - currentTime: number, 11 - skipTime?: number | null, 12 - ): "always" | "hover" | "none" { 13 - if (typeof skipTime !== "number") return "none"; 14 - 15 - // Only show during the first 10 seconds of the intro section 16 - if (currentTime >= 0 && currentTime < skipTime) { 17 - if (currentTime <= 10) return "always"; 18 - return "hover"; 19 - } 20 - 21 - return "none"; 22 - } 23 - 24 - function Button(props: { 25 - className: string; 26 - onClick?: () => void; 27 - children: React.ReactNode; 28 - }) { 29 - return ( 30 - <button 31 - className={classNames( 32 - "font-bold rounded h-10 w-40 scale-95 hover:scale-100 transition-all duration-200", 33 - props.className, 34 - )} 35 - type="button" 36 - onClick={props.onClick} 37 - > 38 - {props.children} 39 - </button> 40 - ); 41 - } 42 - 43 - export function SkipIntroButton(props: { 44 - controlsShowing: boolean; 45 - skipTime?: number | null; 46 - inControl: boolean; 47 - }) { 48 - const time = usePlayerStore((s) => s.progress.time); 49 - const status = usePlayerStore((s) => s.status); 50 - const display = usePlayerStore((s) => s.display); 51 - const meta = usePlayerStore((s) => s.meta); 52 - const { addSkipEvent } = useSkipTracking(20); 53 - const showingState = shouldShowSkipButton(time, props.skipTime); 54 - const animation = showingState === "hover" ? "slide-up" : "fade"; 55 - let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]"; 56 - if (showingState === "always") { 57 - bottom = props.controlsShowing 58 - ? bottom 59 - : "bottom-[calc(3rem+env(safe-area-inset-bottom))]"; 60 - } 61 - 62 - const handleSkip = useCallback(() => { 63 - if (typeof props.skipTime === "number" && display) { 64 - const startTime = time; 65 - const endTime = props.skipTime; 66 - const skipDuration = endTime - startTime; 67 - 68 - display.setTime(props.skipTime); 69 - 70 - // Add manual skip event with high confidence (user explicitly clicked skip intro) 71 - addSkipEvent({ 72 - startTime, 73 - endTime, 74 - skipDuration, 75 - confidence: 0.95, // High confidence for explicit user action 76 - meta: meta 77 - ? { 78 - title: 79 - meta.type === "show" && meta.episode 80 - ? `${meta.title} - S${meta.season?.number || 0}E${meta.episode.number || 0}` 81 - : meta.title, 82 - type: meta.type === "movie" ? "Movie" : "TV Show", 83 - tmdbId: meta.tmdbId, 84 - seasonNumber: meta.season?.number, 85 - episodeNumber: meta.episode?.number, 86 - } 87 - : undefined, 88 - }); 89 - 90 - // eslint-disable-next-line no-console 91 - console.log(`Skip intro button used: ${skipDuration}s total`); 92 - } 93 - }, [props.skipTime, display, time, addSkipEvent, meta]); 94 - if (!props.inControl) return null; 95 - 96 - let show = false; 97 - if (showingState === "always") show = true; 98 - else if (showingState === "hover" && props.controlsShowing) show = true; 99 - if (status !== "playing") show = false; 100 - 101 - return ( 102 - <Transition 103 - animation={animation} 104 - show={show} 105 - className="absolute right-[calc(3rem+env(safe-area-inset-right))] bottom-0" 106 - > 107 - <div 108 - className={classNames([ 109 - "absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center space-x-3", 110 - bottom, 111 - ])} 112 - > 113 - <Button 114 - onClick={handleSkip} 115 - className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center" 116 - > 117 - <Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} /> 118 - Skip Intro 119 - </Button> 120 - </div> 121 - </Transition> 122 - ); 123 - }
+211
src/components/player/atoms/SkipSegmentButton.tsx
··· 1 + import classNames from "classnames"; 2 + import { useCallback } from "react"; 3 + import { useTranslation } from "react-i18next"; 4 + 5 + import { Icon, Icons } from "@/components/Icon"; 6 + import { NextEpisodeButton } from "@/components/player/atoms/NextEpisodeButton"; 7 + import { SegmentData } from "@/components/player/hooks/useSkipTime"; 8 + import { useSkipTracking } from "@/components/player/hooks/useSkipTracking"; 9 + import { Transition } from "@/components/utils/Transition"; 10 + import { PlayerMeta } from "@/stores/player/slices/source"; 11 + import { usePlayerStore } from "@/stores/player/store"; 12 + 13 + function getSegmentText( 14 + type: "intro" | "recap" | "credits", 15 + t: (key: string) => string, 16 + ): string { 17 + switch (type) { 18 + case "intro": 19 + return t("player.skipTime.intro"); 20 + case "recap": 21 + return t("player.skipTime.recap"); 22 + case "credits": 23 + return t("player.skipTime.credits"); 24 + default: 25 + return t("player.skipTime.intro"); 26 + } 27 + } 28 + 29 + function shouldShowSkipButton( 30 + currentTime: number, 31 + segment: SegmentData | null, 32 + ): "always" | "hover" | "none" { 33 + if (!segment) return "none"; 34 + 35 + // Convert current time to milliseconds for comparison 36 + const currentTimeMs = currentTime * 1000; 37 + 38 + // Handle start time (null means 0/start of video) 39 + const startMs = segment.start_ms ?? 0; 40 + 41 + // Handle end time (null means end of video, so we show until the end) 42 + const endMs = segment.end_ms ?? Infinity; 43 + 44 + // Check if current time is within the segment 45 + if (currentTimeMs >= startMs && currentTimeMs <= endMs) { 46 + // Show "always" for the first 10 seconds of the segment, then "hover" 47 + const timeInSegment = currentTimeMs - startMs; 48 + if (timeInSegment <= 10000) return "always"; // First 10 seconds 49 + return "hover"; 50 + } 51 + 52 + return "none"; 53 + } 54 + 55 + function Button(props: { 56 + className: string; 57 + onClick?: () => void; 58 + children: React.ReactNode; 59 + }) { 60 + return ( 61 + <button 62 + className={classNames( 63 + "font-bold rounded h-10 w-40 scale-95 hover:scale-100 transition-all duration-200", 64 + props.className, 65 + )} 66 + type="button" 67 + onClick={props.onClick} 68 + > 69 + {props.children} 70 + </button> 71 + ); 72 + } 73 + 74 + function SkipSegmentButton(props: { 75 + controlsShowing: boolean; 76 + segments: SegmentData[]; 77 + inControl: boolean; 78 + onChangeMeta?: (meta: PlayerMeta) => void; 79 + }) { 80 + const { t } = useTranslation(); 81 + const time = usePlayerStore((s) => s.progress.time); 82 + const _duration = usePlayerStore((s) => s.progress.duration); 83 + const status = usePlayerStore((s) => s.status); 84 + const display = usePlayerStore((s) => s.display); 85 + const meta = usePlayerStore((s) => s.meta); 86 + const { addSkipEvent } = useSkipTracking(20); 87 + 88 + // Check if we should show NextEpisodeButton instead of credits skip button 89 + const shouldShowNextEpisodeInsteadOfCredits = 90 + meta?.type === "show" && 91 + props.segments.some((segment) => { 92 + if (segment.type !== "credits") return false; 93 + // Show NextEpisodeButton if credits end at video end (null means end of video) 94 + return segment.end_ms === null; 95 + }); 96 + 97 + // Find segments that should be shown at the current time 98 + const activeSegments = props.segments.filter((segment) => { 99 + // Skip credits segments if we're showing NextEpisodeButton instead 100 + if (segment.type === "credits" && shouldShowNextEpisodeInsteadOfCredits) { 101 + return false; 102 + } 103 + const showingState = shouldShowSkipButton(time, segment); 104 + return showingState !== "none"; 105 + }); 106 + 107 + const handleSkip = useCallback( 108 + (segment: SegmentData) => { 109 + if (!display) return; 110 + 111 + const startTime = time; 112 + // Skip to the end of the segment (or end of video if end_ms is null) 113 + const targetTime = segment.end_ms ? segment.end_ms / 1000 : _duration; 114 + const skipDuration = targetTime - startTime; 115 + display.setTime(targetTime); 116 + 117 + // Add manual skip event with high confidence (user explicitly clicked skip) 118 + addSkipEvent({ 119 + startTime, 120 + endTime: targetTime, 121 + skipDuration, 122 + confidence: 0.95, // High confidence for explicit user action 123 + meta: meta 124 + ? { 125 + title: 126 + meta.type === "show" && meta.episode 127 + ? `${meta.title} - S${meta.season?.number || 0}E${meta.episode.number || 0}` 128 + : meta.title, 129 + type: meta.type === "movie" ? "Movie" : "TV Show", 130 + tmdbId: meta.tmdbId, 131 + seasonNumber: meta.season?.number, 132 + episodeNumber: meta.episode?.number, 133 + } 134 + : undefined, 135 + }); 136 + 137 + // eslint-disable-next-line no-console 138 + console.log(`Skip ${segment.type} button used: ${skipDuration}s total`); 139 + }, 140 + [display, time, _duration, addSkipEvent, meta], 141 + ); 142 + 143 + // Show NextEpisodeButton instead of credits skip button for TV shows when credits end at video end 144 + if (shouldShowNextEpisodeInsteadOfCredits && props.inControl) { 145 + return ( 146 + <NextEpisodeButton 147 + controlsShowing={props.controlsShowing} 148 + onChange={props.onChangeMeta} 149 + inControl={props.inControl} 150 + /> 151 + ); 152 + } 153 + 154 + if (!props.inControl || activeSegments.length === 0) return null; 155 + 156 + // If status is not playing, don't show buttons 157 + if (status !== "playing") return null; 158 + 159 + return ( 160 + <div className="absolute right-[calc(3rem+env(safe-area-inset-right))] bottom-0"> 161 + {activeSegments.map((segment, index) => { 162 + const showingState = shouldShowSkipButton(time, segment); 163 + const animation = showingState === "hover" ? "slide-up" : "fade"; 164 + 165 + let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]"; 166 + if (showingState === "always") { 167 + bottom = props.controlsShowing 168 + ? bottom 169 + : "bottom-[calc(3rem+env(safe-area-inset-bottom))]"; 170 + } 171 + 172 + // Offset multiple buttons vertically 173 + const verticalOffset = index * 60; // 60px spacing between buttons 174 + const adjustedBottom = bottom.replace( 175 + /bottom-\[calc\(([^)]+)\)\]/, 176 + `bottom-[calc($1 + ${verticalOffset}px)]`, 177 + ); 178 + 179 + let show = false; 180 + if (showingState === "always") show = true; 181 + else if (showingState === "hover" && props.controlsShowing) show = true; 182 + 183 + return ( 184 + <Transition 185 + key={segment.type} 186 + animation={animation} 187 + show={show} 188 + className="absolute right-0" 189 + > 190 + <div 191 + className={classNames([ 192 + "absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center space-x-3", 193 + adjustedBottom, 194 + ])} 195 + > 196 + <Button 197 + onClick={() => handleSkip(segment)} 198 + className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center" 199 + > 200 + <Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} /> 201 + {getSegmentText(segment.type, t)} 202 + </Button> 203 + </div> 204 + </Transition> 205 + ); 206 + })} 207 + </div> 208 + ); 209 + } 210 + 211 + export { SkipSegmentButton };
+83 -20
src/components/player/hooks/useSkipTime.ts
··· 27 27 return currentSkipTimeSource; 28 28 } 29 29 30 + export interface SegmentData { 31 + type: "intro" | "recap" | "credits"; 32 + start_ms: number | null; 33 + end_ms: number | null; 34 + confidence: number | null; 35 + submission_count: number; 36 + } 37 + 30 38 export function useSkipTime() { 31 39 const { playerMeta: meta } = usePlayerMeta(); 32 - const [skiptime, setSkiptime] = useState<number | null>(null); 40 + const [segments, setSegments] = useState<SegmentData[]>([]); 33 41 const febboxKey = usePreferencesStore((s) => s.febboxKey); 34 42 35 43 useEffect(() => { 36 - const fetchTheIntroDBTime = async (): Promise<number | null> => { 37 - if (!meta?.tmdbId) return null; 44 + const fetchTheIntroDBSegments = async (): Promise<SegmentData[]> => { 45 + if (!meta?.tmdbId) return []; 38 46 39 47 try { 40 - let apiUrl = `${THE_INTRO_DB_BASE_URL}/intro?tmdb_id=${meta.tmdbId}`; 48 + let apiUrl = `${THE_INTRO_DB_BASE_URL}/media?tmdb_id=${meta.tmdbId}`; 41 49 if ( 42 50 meta.type !== "movie" && 43 51 meta.season?.number && ··· 48 56 49 57 const data = await mwFetch(apiUrl); 50 58 51 - if (data && typeof data.end_ms === "number") { 52 - // Convert milliseconds to seconds 53 - return Math.floor(data.end_ms / 1000); 59 + const fetchedSegments: SegmentData[] = []; 60 + 61 + // Add intro segment if it has data 62 + if (data?.intro && data.intro.submission_count > 0) { 63 + fetchedSegments.push({ 64 + type: "intro", 65 + start_ms: data.intro.start_ms, 66 + end_ms: data.intro.end_ms, 67 + confidence: data.intro.confidence, 68 + submission_count: data.intro.submission_count, 69 + }); 70 + } 71 + 72 + // Add recap segment if it has data 73 + if (data?.recap && data.recap.submission_count > 0) { 74 + fetchedSegments.push({ 75 + type: "recap", 76 + start_ms: data.recap.start_ms, 77 + end_ms: data.recap.end_ms, 78 + confidence: data.recap.confidence, 79 + submission_count: data.recap.submission_count, 80 + }); 81 + } 82 + 83 + // Add credits segment if it has data 84 + if (data?.credits && data.credits.submission_count > 0) { 85 + fetchedSegments.push({ 86 + type: "credits", 87 + start_ms: data.credits.start_ms, 88 + end_ms: data.credits.end_ms, 89 + confidence: data.credits.confidence, 90 + submission_count: data.credits.submission_count, 91 + }); 54 92 } 55 93 56 - return null; 94 + return fetchedSegments; 57 95 } catch (error) { 58 - console.error("Error fetching TIDB time:", error); 59 - return null; 96 + console.error("Error fetching TIDB segments:", error); 97 + return []; 60 98 } 61 99 }; 62 100 ··· 187 225 }; 188 226 189 227 const fetchSkipTime = async (): Promise<void> => { 190 - // Reset source 228 + // Reset source and segments 191 229 currentSkipTimeSource = null; 230 + setSegments([]); 192 231 193 - // Try TheIntroDB API first (supports both movies and TV shows) 194 - const theIntroDBTime = await fetchTheIntroDBTime(); 195 - if (theIntroDBTime !== null) { 232 + // Try TheIntroDB API first (supports both movies and TV shows with full segment data) 233 + const theIntroDBSegments = await fetchTheIntroDBSegments(); 234 + if (theIntroDBSegments.length > 0) { 196 235 currentSkipTimeSource = "theintrodb"; 197 - setSkiptime(theIntroDBTime); 236 + setSegments(theIntroDBSegments); 198 237 return; 199 238 } 200 239 201 - // Try QuickWatch API (TV shows only) 240 + // Try QuickWatch API (TV shows only) - convert to intro segment 202 241 const quickWatchTime = await fetchQuickWatchTime(); 203 242 if (quickWatchTime !== null) { 204 243 currentSkipTimeSource = "quickwatch"; 205 - setSkiptime(quickWatchTime); 244 + setSegments([ 245 + { 246 + type: "intro", 247 + start_ms: 0, // Assume starts at beginning 248 + end_ms: quickWatchTime * 1000, // Convert seconds to milliseconds 249 + confidence: null, 250 + submission_count: 1, 251 + }, 252 + ]); 206 253 return; 207 254 } 208 255 ··· 212 259 const fedSkipsTime = await fetchFedSkipsTime(); 213 260 if (fedSkipsTime !== null) { 214 261 currentSkipTimeSource = "fed-skips"; 215 - setSkiptime(fedSkipsTime); 262 + setSegments([ 263 + { 264 + type: "intro", 265 + start_ms: 0, // Assume starts at beginning 266 + end_ms: fedSkipsTime * 1000, // Convert seconds to milliseconds 267 + confidence: null, 268 + submission_count: 1, 269 + }, 270 + ]); 216 271 return; 217 272 } 218 273 } ··· 221 276 const introDBTime = await fetchIntroDBTime(); 222 277 if (introDBTime !== null) { 223 278 currentSkipTimeSource = "introdb"; 279 + setSegments([ 280 + { 281 + type: "intro", 282 + start_ms: 0, // Assume starts at beginning 283 + end_ms: introDBTime * 1000, // Convert seconds to milliseconds 284 + confidence: null, 285 + submission_count: 1, 286 + }, 287 + ]); 224 288 } 225 - setSkiptime(introDBTime); 226 289 }; 227 290 228 291 fetchSkipTime(); ··· 236 299 febboxKey, 237 300 ]); 238 301 239 - return skiptime; 302 + return segments; 240 303 }
+5 -4
src/pages/parts/player/PlayerPart.tsx
··· 3 3 4 4 import { BrandPill } from "@/components/layout/BrandPill"; 5 5 import { Player } from "@/components/player"; 6 - import { SkipIntroButton } from "@/components/player/atoms/SkipIntroButton"; 6 + import { SkipSegmentButton } from "@/components/player/atoms/SkipSegmentButton"; 7 7 import { UnreleasedEpisodeOverlay } from "@/components/player/atoms/UnreleasedEpisodeOverlay"; 8 8 import { WatchPartyStatus } from "@/components/player/atoms/WatchPartyStatus"; 9 9 import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls"; ··· 74 74 }, 1000); 75 75 }; 76 76 77 - const skiptime = useSkipTime(); 77 + const segments = useSkipTime(); 78 78 79 79 return ( 80 80 <Player.Container onLoad={props.onLoad} showingControls={showTargets}> ··· 246 246 inControl={inControl} 247 247 /> 248 248 249 - <SkipIntroButton 249 + <SkipSegmentButton 250 250 controlsShowing={showTargets} 251 - skipTime={skiptime} 251 + segments={segments} 252 252 inControl={inControl} 253 + onChangeMeta={props.onMetaChange} 253 254 /> 254 255 </Player.Container> 255 256 );