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.

fix skip section buttons not showing

Pas a9e2ff2d 64a241e2

+171 -123
+4 -1
src/components/player/atoms/NextEpisodeButton.tsx
··· 96 96 onChange?: (meta: PlayerMeta) => void; 97 97 inControl: boolean; 98 98 showAsButton?: boolean; 99 + /** When true (e.g. in credits-to-end segment), show regardless of time/duration. */ 100 + forceShow?: boolean; 99 101 }) { 100 102 const { t } = useTranslation(); 101 103 const duration = usePlayerStore((s) => s.progress.duration); ··· 109 111 const setLastSuccessfulSource = usePreferencesStore( 110 112 (s) => s.setLastSuccessfulSource, 111 113 ); 112 - const showingState = shouldShowNextEpisodeButton(time, duration); 114 + const timeBasedState = shouldShowNextEpisodeButton(time, duration); 115 + const showingState = props.forceShow ? "always" : timeBasedState; 113 116 const status = usePlayerStore((s) => s.status); 114 117 const setShouldStartFromBeginning = usePlayerStore( 115 118 (s) => s.setShouldStartFromBeginning,
+70 -59
src/components/player/atoms/SkipSegmentButton.tsx
··· 86 86 const meta = usePlayerStore((s) => s.meta); 87 87 const { addSkipEvent } = useSkipTracking(20); 88 88 89 - // Check if we should show NextEpisodeButton instead of credits skip button 89 + // Only replace with NextEpisodeButton when credits have no end (end_ms === null) – i.e. credits 90 + // run to the end of the video. When end_ms is a number, there may be content after (e.g. post- 91 + // credits scene), so we show the normal "Skip credits" button that seeks to end_ms. 90 92 const shouldShowNextEpisodeInsteadOfCredits = 91 93 meta?.type === "show" && 92 94 props.segments.some((segment) => { 93 95 if (segment.type !== "credits") return false; 94 - // Show NextEpisodeButton if credits end at video end (null means end of video) 95 96 return segment.end_ms === null; 96 97 }); 97 98 98 - // Find segments that should be shown at the current time 99 + // Find segments that should be shown at the current time (intro, recap; credits excluded when we show NextEpisodeButton) 99 100 const activeSegments = props.segments.filter((segment) => { 100 - // Skip credits segments if we're showing NextEpisodeButton instead 101 101 if (segment.type === "credits" && shouldShowNextEpisodeInsteadOfCredits) { 102 102 return false; 103 103 } 104 104 const showingState = shouldShowSkipButton(time, segment); 105 105 return showingState !== "none"; 106 106 }); 107 + 108 + // NextEpisodeButton only for the "credits to end of video" segment (end_ms === null) 109 + const creditsSegment = props.segments.find( 110 + (s) => s.type === "credits" && s.end_ms === null, 111 + ); 112 + const inCreditsSegment = 113 + creditsSegment != null && time * 1000 >= (creditsSegment.start_ms ?? 0); 114 + const showNextEpisodeButton = 115 + shouldShowNextEpisodeInsteadOfCredits && 116 + props.inControl && 117 + inCreditsSegment; 107 118 108 119 const handleSkip = useCallback( 109 120 (segment: SegmentData) => { ··· 146 157 [display, time, _duration, addSkipEvent, meta, props], 147 158 ); 148 159 149 - // Show NextEpisodeButton instead of credits skip button for TV shows when credits end at video end 150 - if (shouldShowNextEpisodeInsteadOfCredits && props.inControl) { 151 - return ( 152 - <NextEpisodeButton 153 - controlsShowing={props.controlsShowing} 154 - onChange={props.onChangeMeta} 155 - inControl={props.inControl} 156 - /> 157 - ); 158 - } 159 - 160 - if (!props.inControl || activeSegments.length === 0) return null; 161 - 162 - // If status is not playing, don't show buttons 160 + if (!props.inControl) return null; 163 161 if (status !== "playing") return null; 162 + if (activeSegments.length === 0 && !showNextEpisodeButton) return null; 164 163 165 164 return ( 166 - <div className="absolute right-[calc(3rem+env(safe-area-inset-right))] bottom-0"> 167 - {activeSegments.map((segment, index) => { 168 - const showingState = shouldShowSkipButton(time, segment); 169 - const animation = showingState === "hover" ? "slide-up" : "fade"; 165 + <> 166 + <div className="absolute right-[calc(3rem+env(safe-area-inset-right))] bottom-0"> 167 + {activeSegments.map((segment, index) => { 168 + const showingState = shouldShowSkipButton(time, segment); 169 + const animation = showingState === "hover" ? "slide-up" : "fade"; 170 170 171 - let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]"; 172 - if (showingState === "always") { 173 - bottom = props.controlsShowing 174 - ? bottom 175 - : "bottom-[calc(3rem+env(safe-area-inset-bottom))]"; 176 - } 171 + let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]"; 172 + if (showingState === "always") { 173 + bottom = props.controlsShowing 174 + ? bottom 175 + : "bottom-[calc(3rem+env(safe-area-inset-bottom))]"; 176 + } 177 177 178 - // Offset multiple buttons vertically 179 - const verticalOffset = index * 60; // 60px spacing between buttons 180 - const adjustedBottom = bottom.replace( 181 - /bottom-\[calc\(([^)]+)\)\]/, 182 - `bottom-[calc($1 + ${verticalOffset}px)]`, 183 - ); 178 + // Offset multiple buttons vertically 179 + const verticalOffset = index * 60; // 60px spacing between buttons 180 + const adjustedBottom = bottom.replace( 181 + /bottom-\[calc\(([^)]+)\)\]/, 182 + `bottom-[calc($1 + ${verticalOffset}px)]`, 183 + ); 184 184 185 - // Show button whenever we're in a segment (not only on hover after first 10s) 186 - const show = showingState === "always" || showingState === "hover"; 185 + let show = false; 186 + if (showingState === "always") show = true; 187 + else if (showingState === "hover" && props.controlsShowing) 188 + show = true; 187 189 188 - return ( 189 - <Transition 190 - key={segment.type} 191 - animation={animation} 192 - show={show} 193 - className="absolute right-0" 194 - > 195 - <div 196 - className={classNames([ 197 - "absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center space-x-3", 198 - adjustedBottom, 199 - ])} 190 + return ( 191 + <Transition 192 + key={segment.type} 193 + animation={animation} 194 + show={show} 195 + className="absolute right-0" 200 196 > 201 - <Button 202 - onClick={() => handleSkip(segment)} 203 - className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center" 197 + <div 198 + className={classNames([ 199 + "absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center space-x-3", 200 + adjustedBottom, 201 + ])} 204 202 > 205 - <Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} /> 206 - {getSegmentText(segment.type, t)} 207 - </Button> 208 - </div> 209 - </Transition> 210 - ); 211 - })} 212 - </div> 203 + <Button 204 + onClick={() => handleSkip(segment)} 205 + className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center" 206 + > 207 + <Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} /> 208 + {getSegmentText(segment.type, t)} 209 + </Button> 210 + </div> 211 + </Transition> 212 + ); 213 + })} 214 + </div> 215 + {showNextEpisodeButton && ( 216 + <NextEpisodeButton 217 + controlsShowing={props.controlsShowing} 218 + onChange={props.onChangeMeta} 219 + inControl={props.inControl} 220 + forceShow 221 + /> 222 + )} 223 + </> 213 224 ); 214 225 } 215 226
+97 -63
src/components/player/hooks/useSkipTime.ts
··· 4 4 import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch"; 5 5 import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; 6 6 import { conf } from "@/setup/config"; 7 - import { getMediaKey } from "@/stores/player/slices/source"; 7 + import type { PlayerMeta } from "@/stores/player/slices/source"; 8 8 import { usePlayerStore } from "@/stores/player/store"; 9 9 import { usePreferencesStore } from "@/stores/preferences"; 10 10 import { getTurnstileToken } from "@/utils/turnstile"; ··· 18 18 // Track the source of the current skip time (for analytics filtering) 19 19 let currentSkipTimeSource: "fed-skips" | "introdb" | "theintrodb" | null = null; 20 20 21 + // Prevent multiple components from triggering overlapping fetches for the same media 22 + let fetchingForCacheKey: string | null = null; 23 + 24 + /** Cache key for skip segments – matches TIDB API (tmdbId + season + episode number). */ 25 + function getSkipSegmentsCacheKey(meta: PlayerMeta | null): string | null { 26 + if (!meta?.tmdbId) return null; 27 + if (meta.type === "movie") return `skip-${meta.type}-${meta.tmdbId}`; 28 + if (meta.type === "show" && meta.season != null && meta.episode != null) { 29 + return `skip-${meta.type}-${meta.tmdbId}-${meta.season.number}-${meta.episode.number}`; 30 + } 31 + return null; 32 + } 33 + 21 34 export function useSkipTimeSource(): typeof currentSkipTimeSource { 22 35 return currentSkipTimeSource; 23 36 } ··· 33 46 export function useSkipTime() { 34 47 const { playerMeta: meta } = usePlayerMeta(); 35 48 const febboxKey = usePreferencesStore((s) => s.febboxKey); 36 - const cacheKey = getMediaKey(meta ?? null); 49 + const cacheKey = getSkipSegmentsCacheKey(meta ?? null); 37 50 const skipSegmentsCacheKey = usePlayerStore((s) => s.skipSegmentsCacheKey); 38 51 const skipSegments = usePlayerStore((s) => s.skipSegments); 39 52 const setSkipSegments = usePlayerStore((s) => s.setSkipSegments); ··· 41 54 useEffect(() => { 42 55 if (!cacheKey) return; 43 56 // Already have segments for this media – don't refetch (e.g. when opening menu) 44 - if (cacheKey === skipSegmentsCacheKey) return; 57 + if (usePlayerStore.getState().skipSegmentsCacheKey === cacheKey) return; 58 + // Another fetch for this key is already in progress (e.g. two components mounted) 59 + if (fetchingForCacheKey === cacheKey) return; 60 + fetchingForCacheKey = cacheKey; 45 61 // Validate segment data according to rules 46 62 // eslint-disable-next-line camelcase 47 63 const validateSegment = ( ··· 86 102 return false; 87 103 }; 88 104 89 - const fetchTheIntroDBSegments = async (): Promise<SegmentData[]> => { 90 - if (!meta?.tmdbId) return []; 105 + const fetchTheIntroDBSegments = async (): Promise<{ 106 + segments: SegmentData[]; 107 + tidbNotFound: boolean; 108 + }> => { 109 + if (!meta?.tmdbId) return { segments: [], tidbNotFound: false }; 91 110 92 111 try { 93 112 let apiUrl = `${THE_INTRO_DB_BASE_URL}/media?tmdb_id=${meta.tmdbId}`; ··· 148 167 }); 149 168 } 150 169 151 - return fetchedSegments; 152 - } catch (error) { 170 + // TIDB returned 200 – we have segment data for this media (even if no intro) 171 + return { segments: fetchedSegments, tidbNotFound: false }; 172 + } catch (error: unknown) { 173 + const err = error as { 174 + response?: { status?: number }; 175 + status?: number; 176 + }; 177 + const status = err?.response?.status ?? err?.status; 178 + if (status === 404) { 179 + return { segments: [], tidbNotFound: true }; 180 + } 153 181 console.error("Error fetching TIDB segments:", error); 154 - return []; 182 + return { segments: [], tidbNotFound: false }; 155 183 } 156 184 }; 157 185 ··· 219 247 } 220 248 }; 221 249 250 + const applySegments = (segmentsToApply: SegmentData[]) => { 251 + // Only update store if this fetch is still for the current media (avoid stale overwrite) 252 + const currentKey = getSkipSegmentsCacheKey( 253 + usePlayerStore.getState().meta ?? null, 254 + ); 255 + if (currentKey === cacheKey) { 256 + setSkipSegments(cacheKey, segmentsToApply); 257 + } 258 + }; 259 + 222 260 const fetchSkipTime = async (): Promise<void> => { 223 261 currentSkipTimeSource = null; 224 262 225 - // Try TheIntroDB API first (supports both movies and TV shows with full segment data) 226 - const theIntroDBSegments = await fetchTheIntroDBSegments(); 227 - const hasIntroSegment = theIntroDBSegments.some( 228 - (s) => s.type === "intro", 229 - ); 230 - const nonIntroSegments = theIntroDBSegments.filter( 231 - (s) => s.type !== "intro", 232 - ); 263 + try { 264 + // Try TheIntroDB API first (supports both movies and TV shows with full segment data) 265 + const { segments: tidbSegments, tidbNotFound } = 266 + await fetchTheIntroDBSegments(); 233 267 234 - // If we have a valid intro from TIDB, use all TIDB segments 235 - if (hasIntroSegment) { 236 - currentSkipTimeSource = "theintrodb"; 237 - setSkipSegments(cacheKey, theIntroDBSegments); 238 - return; 239 - } 268 + // TIDB returned 200 – use whatever segments we got (intro, recap, credits; may be empty) 269 + if (!tidbNotFound) { 270 + currentSkipTimeSource = "theintrodb"; 271 + applySegments(tidbSegments); 272 + return; 273 + } 240 274 241 - // If TIDB doesn't have a valid intro, try fallbacks to get intro data 242 - // But keep any valid recap/credits segments from TIDB 243 - let fallbackIntroSegment: SegmentData | null = null; 275 + // TIDB returned 404 – no segment data for this media; try fallbacks for intro only 276 + const nonIntroSegments: SegmentData[] = []; 277 + let fallbackIntroSegment: SegmentData | null = null; 244 278 245 - // Fall back to Fed-skips if TheIntroDB doesn't have intro 246 - // Note: Fed-skips only supports TV shows, not movies 247 - if (febboxKey && meta?.type !== "movie") { 248 - const fedSkipsTime = await fetchFedSkipsTime(); 249 - if (fedSkipsTime !== null) { 250 - currentSkipTimeSource = "fed-skips"; 251 - fallbackIntroSegment = { 252 - type: "intro", 253 - start_ms: 0, // Assume starts at beginning 254 - end_ms: fedSkipsTime * 1000, // Convert seconds to milliseconds 255 - confidence: null, 256 - submission_count: 1, 257 - }; 279 + // Fall back to Fed-skips (TV shows only) 280 + if (febboxKey && meta?.type !== "movie") { 281 + const fedSkipsTime = await fetchFedSkipsTime(); 282 + if (fedSkipsTime !== null) { 283 + currentSkipTimeSource = "fed-skips"; 284 + fallbackIntroSegment = { 285 + type: "intro", 286 + start_ms: 0, 287 + end_ms: fedSkipsTime * 1000, 288 + confidence: null, 289 + submission_count: 1, 290 + }; 291 + } 258 292 } 259 - } 260 293 261 - // Last resort: Fall back to IntroDB API (TV shows only, available to all users) 262 - if (!fallbackIntroSegment) { 263 - const introDBTime = await fetchIntroDBTime(); 264 - if (introDBTime !== null) { 265 - currentSkipTimeSource = "introdb"; 266 - fallbackIntroSegment = { 267 - type: "intro", 268 - start_ms: 0, // Assume starts at beginning 269 - end_ms: introDBTime * 1000, // Convert seconds to milliseconds 270 - confidence: null, 271 - submission_count: 1, 272 - }; 294 + // Last resort: IntroDB API (TV shows only) 295 + if (!fallbackIntroSegment && meta?.type !== "movie") { 296 + const introDBTime = await fetchIntroDBTime(); 297 + if (introDBTime !== null) { 298 + currentSkipTimeSource = "introdb"; 299 + fallbackIntroSegment = { 300 + type: "intro", 301 + start_ms: 0, 302 + end_ms: introDBTime * 1000, 303 + confidence: null, 304 + submission_count: 1, 305 + }; 306 + } 273 307 } 274 - } 275 308 276 - // Combine fallback intro with any valid TIDB segments (recap/credits) 277 - const finalSegments: SegmentData[] = []; 278 - if (fallbackIntroSegment) { 279 - finalSegments.push(fallbackIntroSegment); 280 - } 281 - // Add any valid recap/credits segments from TIDB 282 - finalSegments.push(...nonIntroSegments); 309 + const finalSegments: SegmentData[] = []; 310 + if (fallbackIntroSegment) { 311 + finalSegments.push(fallbackIntroSegment); 312 + } 313 + finalSegments.push(...nonIntroSegments); 283 314 284 - // Always update cache (even when empty) so we don't refetch for this media 285 - setSkipSegments(cacheKey, finalSegments); 315 + applySegments(finalSegments); 316 + } finally { 317 + if (fetchingForCacheKey === cacheKey) { 318 + fetchingForCacheKey = null; 319 + } 320 + } 286 321 }; 287 322 288 323 fetchSkipTime(); 289 324 }, [ 290 325 cacheKey, 291 - skipSegmentsCacheKey, 292 - setSkipSegments, 293 326 meta?.tmdbId, 294 327 meta?.imdbId, 295 328 meta?.title, ··· 297 330 meta?.season?.number, 298 331 meta?.episode?.number, 299 332 febboxKey, 333 + setSkipSegments, 300 334 ]); 301 335 302 336 // Only return segments when they're for the current media (avoid showing stale data)