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.

Add auto-resume on playback error setting and logic

Introduces a new user preference to automatically resume playback from the next available source when a playback error occurs. Updates settings UI, preferences store, and player error handling to support this feature, including new translations and backend support. Manual resume remains available if the feature is disabled.

Pas 4ced2562 32f7178a

+231 -38
+5
src/assets/locales/en.json
··· 841 841 "errorNetwork": "Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.", 842 842 "errorNotSupported": "The media or media provider object is not supported." 843 843 }, 844 + "autoResumeText": "There was an error trying to play the media 😖. Automatically trying the next source...", 844 845 "copyDebugInfo": "Copy debug info", 845 846 "debugInfo": "Check console for more details.", 846 847 "homeButton": "Go home", 848 + "resumeButton": "Try next source", 847 849 "text": "There was an error trying to play the media 😖. Please try again or try a different source!", 848 850 "title": "Failed to play video!" 849 851 }, ··· 1135 1137 "manualSource": "Manual source selection", 1136 1138 "manualSourceDescription": "Require picking a source before scraping. Disables automatic source selection and opens the source picker when starting playback.", 1137 1139 "manualSourceLabel": "Manual source selection", 1140 + "autoResumeOnPlaybackError": "Auto resume on playback error", 1141 + "autoResumeOnPlaybackErrorDescription": "Automatically continue searching for other sources when the current source fails during playback. If disabled, you'll see an error screen with a manual resume option.", 1142 + "autoResumeOnPlaybackErrorLabel": "Auto resume on playback error", 1138 1143 "lastSuccessfulSource": "Last used source", 1139 1144 "lastSuccessfulSourceDescription": "Automatically prioritize the source that successfully provided content for the previous episode. This helps ensure continuity when watching series.", 1140 1145 "lastSuccessfulSourceEnableLabel": "Last used source"
+2
src/backend/accounts/settings.ts
··· 35 35 homeSectionOrder?: string[] | null; 36 36 manualSourceSelection?: boolean; 37 37 enableDoubleClickToSeek?: boolean; 38 + enableAutoResumeOnPlaybackError?: boolean; 38 39 } 39 40 40 41 export interface SettingsResponse { ··· 69 70 homeSectionOrder?: string[] | null; 70 71 manualSourceSelection?: boolean; 71 72 enableDoubleClickToSeek?: boolean; 73 + enableAutoResumeOnPlaybackError?: boolean; 72 74 } 73 75 74 76 export function updateSettings(
+47 -18
src/hooks/useProviderScrape.tsx
··· 167 167 const disabledEmbeds = usePreferencesStore((s) => s.disabledEmbeds); 168 168 169 169 const startScraping = useCallback( 170 - async (media: ScrapeMedia) => { 171 - // Create source order that prioritizes last successful source 172 - let filteredSourceOrder = enableSourceOrder 173 - ? preferredSourceOrder.filter((id) => !disabledSources.includes(id)) 174 - : undefined; 170 + async (media: ScrapeMedia, startFromSourceId?: string) => { 171 + const providerInstance = getProviders(); 172 + const allSources = providerInstance.listSources(); 173 + 174 + // Start with all available sources (filtered by disabled ones) 175 + let baseSourceOrder = allSources 176 + .filter((source) => !disabledSources.includes(source.id)) 177 + .map((source) => source.id); 178 + 179 + // Apply custom source ordering if enabled 180 + if (enableSourceOrder && preferredSourceOrder.length > 0) { 181 + const orderedSources: string[] = []; 182 + const remainingSources = [...baseSourceOrder]; 183 + 184 + // Add sources in preferred order 185 + for (const sourceId of preferredSourceOrder) { 186 + const sourceIndex = remainingSources.indexOf(sourceId); 187 + if (sourceIndex !== -1) { 188 + orderedSources.push(sourceId); 189 + remainingSources.splice(sourceIndex, 1); 190 + } 191 + } 192 + 193 + // Add remaining sources 194 + baseSourceOrder = [...orderedSources, ...remainingSources]; 195 + } 175 196 176 197 // If we have a last successful source and the feature is enabled, prioritize it 177 198 if (enableLastSuccessfulSource && lastSuccessfulSource) { 178 - // Get all available sources (either from custom order or default) 179 - const availableSources = filteredSourceOrder || []; 180 - 181 - // If the last successful source is not disabled and exists in available sources, 182 - // move it to the front 183 - if ( 184 - !disabledSources.includes(lastSuccessfulSource) && 185 - availableSources.includes(lastSuccessfulSource) 186 - ) { 187 - filteredSourceOrder = [ 199 + const lastSourceIndex = baseSourceOrder.indexOf(lastSuccessfulSource); 200 + if (lastSourceIndex !== -1) { 201 + baseSourceOrder = [ 188 202 lastSuccessfulSource, 189 - ...availableSources.filter((id) => id !== lastSuccessfulSource), 203 + ...baseSourceOrder.filter((id) => id !== lastSuccessfulSource), 190 204 ]; 191 205 } 192 206 } 193 207 208 + // If starting from a specific source ID, filter the order to start AFTER that source 209 + let filteredSourceOrder = baseSourceOrder; 210 + if (startFromSourceId) { 211 + const startIndex = filteredSourceOrder.indexOf(startFromSourceId); 212 + if (startIndex !== -1) { 213 + filteredSourceOrder = filteredSourceOrder.slice(startIndex + 1); 214 + } 215 + } 216 + 194 217 // Filter out disabled embeds from the embed order 195 218 const filteredEmbedOrder = enableEmbedOrder 196 219 ? preferredEmbedOrder.filter((id) => !disabledEmbeds.includes(id)) ··· 223 246 const providers = getProviders(); 224 247 const output = await providers.runAll({ 225 248 media, 226 - // Only pass sourceOrder if enableSourceOrder is true, and filter out disabled sources 227 249 sourceOrder: filteredSourceOrder, 228 - // Only pass embedOrder if enableEmbedOrder is true 229 250 embedOrder: filteredEmbedOrder, 230 251 events: { 231 252 init: initEvent, ··· 256 277 ], 257 278 ); 258 279 280 + const resumeScraping = useCallback( 281 + async (media: ScrapeMedia, startFromSourceId: string) => { 282 + return startScraping(media, startFromSourceId); 283 + }, 284 + [startScraping], 285 + ); 286 + 259 287 return { 260 288 startScraping, 289 + resumeScraping, 261 290 sourceOrder, 262 291 sources, 263 292 currentSource,
+15 -1
src/hooks/useSettingsState.ts
··· 79 79 homeSectionOrder: string[], 80 80 manualSourceSelection: boolean, 81 81 enableDoubleClickToSeek: boolean, 82 + enableAutoResumeOnPlaybackError: boolean, 82 83 ) { 83 84 const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] = 84 85 useDerived(proxyUrls); ··· 262 263 resetEnableDoubleClickToSeek, 263 264 enableDoubleClickToSeekChanged, 264 265 ] = useDerived(enableDoubleClickToSeek); 266 + const [ 267 + enableAutoResumeOnPlaybackErrorState, 268 + setEnableAutoResumeOnPlaybackErrorState, 269 + resetEnableAutoResumeOnPlaybackError, 270 + enableAutoResumeOnPlaybackErrorChanged, 271 + ] = useDerived(enableAutoResumeOnPlaybackError); 265 272 266 273 function reset() { 267 274 resetTheme(); ··· 299 306 resetHomeSectionOrder(); 300 307 resetManualSourceSelection(); 301 308 resetEnableDoubleClickToSeek(); 309 + resetEnableAutoResumeOnPlaybackError(); 302 310 } 303 311 304 312 const changed = ··· 336 344 enableHoldToBoostChanged || 337 345 homeSectionOrderChanged || 338 346 manualSourceSelectionChanged || 339 - enableDoubleClickToSeekChanged; 347 + enableDoubleClickToSeekChanged || 348 + enableAutoResumeOnPlaybackErrorChanged; 340 349 341 350 return { 342 351 reset, ··· 515 524 state: enableDoubleClickToSeekState, 516 525 set: setEnableDoubleClickToSeekState, 517 526 changed: enableDoubleClickToSeekChanged, 527 + }, 528 + enableAutoResumeOnPlaybackError: { 529 + state: enableAutoResumeOnPlaybackErrorState, 530 + set: setEnableAutoResumeOnPlaybackErrorState, 531 + changed: enableAutoResumeOnPlaybackErrorChanged, 518 532 }, 519 533 }; 520 534 }
+27 -1
src/pages/PlayerView.tsx
··· 25 25 import { SourceSelectPart } from "@/pages/parts/player/SourceSelectPart"; 26 26 import { useLastNonPlayerLink } from "@/stores/history"; 27 27 import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; 28 + import { usePlayerStore } from "@/stores/player/store"; 28 29 import { usePreferencesStore } from "@/stores/preferences"; 29 30 import { getProgressPercentage, useProgressStore } from "@/stores/progress"; 30 31 import { needsOnboarding } from "@/utils/onboarding"; ··· 41 42 sources: Record<string, ScrapingSegment>; 42 43 sourceOrder: ScrapingItems[]; 43 44 } | null>(null); 45 + const [resumeFromSourceId, setResumeFromSourceId] = useState<string | null>( 46 + null, 47 + ); 44 48 const [startAtParam] = useQueryParam("t"); 45 49 const { 46 50 status, ··· 51 55 setShouldStartFromBeginning, 52 56 setStatus, 53 57 } = usePlayer(); 58 + const sourceId = usePlayerStore((s) => s.sourceId); 54 59 const { setPlayerMeta, scrapeMedia } = usePlayerMeta(); 55 60 const backUrl = useLastNonPlayerLink(); 56 61 const manualSourceSelection = usePreferencesStore( ··· 158 163 setStatus(playerStatus.SCRAPING); 159 164 }, [setShouldStartFromBeginning, setStatus]); 160 165 166 + const handleResumeScraping = useCallback( 167 + (startFromSourceId: string) => { 168 + // Set resume source first 169 + setResumeFromSourceId(startFromSourceId); 170 + // Then change status in next tick to ensure re-render 171 + setTimeout(() => { 172 + setStatus(playerStatus.SCRAPING); 173 + }, 0); 174 + }, 175 + [setStatus], 176 + ); 177 + 161 178 const playAfterScrape = useCallback( 162 179 (out: RunOutput | null) => { 163 180 if (!out) return; ··· 198 215 <SourceSelectPart media={scrapeMedia} /> 199 216 ) : ( 200 217 <ScrapingPart 218 + key={`scraping-${resumeFromSourceId || "default"}`} 201 219 media={scrapeMedia} 220 + startFromSourceId={resumeFromSourceId || undefined} 202 221 onResult={(sources, sourceOrder) => { 203 222 setErrorData({ 204 223 sourceOrder, 205 224 sources, 206 225 }); 207 226 setScrapeNotFound(); 227 + // Clear resume state after scraping 228 + setResumeFromSourceId(null); 208 229 }} 209 230 onGetStream={playAfterScrape} 210 231 /> ··· 213 234 {status === playerStatus.SCRAPE_NOT_FOUND && errorData ? ( 214 235 <ScrapeErrorPart data={errorData} /> 215 236 ) : null} 216 - {status === playerStatus.PLAYBACK_ERROR ? <PlaybackErrorPart /> : null} 237 + {status === playerStatus.PLAYBACK_ERROR ? ( 238 + <PlaybackErrorPart 239 + onResume={handleResumeScraping} 240 + currentSourceId={sourceId} 241 + /> 242 + ) : null} 217 243 </PlayerPart> 218 244 ); 219 245 }
+22 -1
src/pages/Settings.tsx
··· 486 486 (s) => s.setEnableDoubleClickToSeek, 487 487 ); 488 488 489 + const enableAutoResumeOnPlaybackError = usePreferencesStore( 490 + (s) => s.enableAutoResumeOnPlaybackError, 491 + ); 492 + const setEnableAutoResumeOnPlaybackError = usePreferencesStore( 493 + (s) => s.setEnableAutoResumeOnPlaybackError, 494 + ); 495 + 489 496 const account = useAuthStore((s) => s.account); 490 497 const updateProfile = useAuthStore((s) => s.setAccountProfile); 491 498 const updateDeviceName = useAuthStore((s) => s.updateDeviceName); ··· 557 564 homeSectionOrder, 558 565 manualSourceSelection, 559 566 enableDoubleClickToSeek, 567 + enableAutoResumeOnPlaybackError, 560 568 ); 561 569 562 570 const availableSources = useMemo(() => { ··· 622 630 state.enableHoldToBoost.changed || 623 631 state.homeSectionOrder.changed || 624 632 state.manualSourceSelection.changed || 625 - state.enableDoubleClickToSeek 633 + state.enableDoubleClickToSeek.changed || 634 + state.enableAutoResumeOnPlaybackError 626 635 ) { 627 636 await updateSettings(backendUrl, account, { 628 637 applicationLanguage: state.appLanguage.state, ··· 651 660 homeSectionOrder: state.homeSectionOrder.state, 652 661 manualSourceSelection: state.manualSourceSelection.state, 653 662 enableDoubleClickToSeek: state.enableDoubleClickToSeek.state, 663 + enableAutoResumeOnPlaybackError: 664 + state.enableAutoResumeOnPlaybackError.state, 654 665 }); 655 666 } 656 667 if (state.deviceName.changed) { ··· 705 716 setHomeSectionOrder(state.homeSectionOrder.state); 706 717 setManualSourceSelection(state.manualSourceSelection.state); 707 718 setEnableDoubleClickToSeek(state.enableDoubleClickToSeek.state); 719 + setEnableAutoResumeOnPlaybackError( 720 + state.enableAutoResumeOnPlaybackError.state, 721 + ); 708 722 709 723 if (state.profile.state) { 710 724 updateProfile(state.profile.state); ··· 757 771 setHomeSectionOrder, 758 772 setManualSourceSelection, 759 773 setEnableDoubleClickToSeek, 774 + setEnableAutoResumeOnPlaybackError, 760 775 ]); 761 776 return ( 762 777 <SubPageLayout> ··· 838 853 setManualSourceSelection={state.manualSourceSelection.set} 839 854 enableDoubleClickToSeek={state.enableDoubleClickToSeek.state} 840 855 setEnableDoubleClickToSeek={state.enableDoubleClickToSeek.set} 856 + enableAutoResumeOnPlaybackError={ 857 + state.enableAutoResumeOnPlaybackError.state 858 + } 859 + setEnableAutoResumeOnPlaybackError={ 860 + state.enableAutoResumeOnPlaybackError.set 861 + } 841 862 /> 842 863 </div> 843 864 )}
+67 -11
src/pages/parts/player/PlaybackErrorPart.tsx
··· 14 14 15 15 import { ErrorCardInModal } from "../errors/ErrorCard"; 16 16 17 - export function PlaybackErrorPart() { 17 + export interface PlaybackErrorPartProps { 18 + onResume?: (startFromSourceId: string) => void; 19 + currentSourceId?: string | null; 20 + } 21 + 22 + export function PlaybackErrorPart(props: PlaybackErrorPartProps) { 18 23 const { t } = useTranslation(); 19 24 const playbackError = usePlayerStore((s) => s.interface.error); 20 25 const modal = useModal("error"); 21 26 const settingsRouter = useOverlayRouter("settings"); 22 27 const hasOpenedSettings = useRef(false); 28 + const hasAutoResumed = useRef(false); 23 29 const setLastSuccessfulSource = usePreferencesStore( 24 30 (s) => s.setLastSuccessfulSource, 25 31 ); 32 + const enableAutoResumeOnPlaybackError = usePreferencesStore( 33 + (s) => s.enableAutoResumeOnPlaybackError, 34 + ); 26 35 27 - // Automatically open the settings overlay when a playback error occurs 36 + // Automatically open the settings overlay when a playback error occurs (unless auto-resume is enabled) 28 37 useEffect(() => { 29 - if (playbackError && !hasOpenedSettings.current) { 38 + if ( 39 + playbackError && 40 + !hasOpenedSettings.current && 41 + !enableAutoResumeOnPlaybackError 42 + ) { 30 43 hasOpenedSettings.current = true; 31 44 // Reset the last successful source when a playback error occurs 32 45 setLastSuccessfulSource(null); 33 46 settingsRouter.open(); 34 47 settingsRouter.navigate("/source"); 35 48 } 36 - }, [playbackError, settingsRouter, setLastSuccessfulSource]); 49 + }, [ 50 + playbackError, 51 + settingsRouter, 52 + setLastSuccessfulSource, 53 + enableAutoResumeOnPlaybackError, 54 + ]); 55 + 56 + // Automatically resume scraping from the next source if enabled 57 + useEffect(() => { 58 + if ( 59 + playbackError && 60 + !hasAutoResumed.current && 61 + enableAutoResumeOnPlaybackError && 62 + props.currentSourceId && 63 + props.onResume 64 + ) { 65 + hasAutoResumed.current = true; 66 + // Immediately call resume without delay since we don't need the overlay 67 + props.onResume!(props.currentSourceId!); 68 + } 69 + }, [ 70 + playbackError, 71 + enableAutoResumeOnPlaybackError, 72 + props.currentSourceId, 73 + props.onResume, 74 + ]); 37 75 38 76 const handleOpenSourcePicker = () => { 39 77 settingsRouter.open(); ··· 45 83 <ErrorContainer> 46 84 <IconPill icon={Icons.WAND}>{t("player.playbackError.badge")}</IconPill> 47 85 <Title>{t("player.playbackError.title")}</Title> 48 - <Paragraph>{t("player.playbackError.text")}</Paragraph> 86 + <Paragraph> 87 + {enableAutoResumeOnPlaybackError 88 + ? t("player.playbackError.autoResumeText") 89 + : t("player.playbackError.text")} 90 + </Paragraph> 49 91 <div className="flex gap-3"> 92 + {props.currentSourceId && 93 + props.onResume && 94 + !enableAutoResumeOnPlaybackError && ( 95 + <Button 96 + onClick={() => props.onResume!(props.currentSourceId!)} 97 + theme="purple" 98 + padding="md:px-12 p-2.5" 99 + className="mt-6" 100 + > 101 + {t("player.playbackError.resumeButton")} 102 + </Button> 103 + )} 50 104 <Button 51 - onClick={() => modal.show()} 52 - theme="danger" 105 + onClick={handleOpenSourcePicker} 106 + theme="purple" 53 107 padding="md:px-12 p-2.5" 54 108 className="mt-6" 55 109 > 56 - {t("errors.showError")} 110 + {t("player.menus.sources.title")} 57 111 </Button> 112 + </div> 113 + <div className="flex gap-3"> 58 114 <Button 59 - onClick={handleOpenSourcePicker} 60 - theme="purple" 115 + onClick={() => modal.show()} 116 + theme="danger" 61 117 padding="md:px-12 p-2.5" 62 118 className="mt-6" 63 119 > 64 - {t("player.menus.sources.title")} 120 + {t("errors.showError")} 65 121 </Button> 66 122 </div> 67 123 <div className="flex gap-3">
+13 -6
src/pages/parts/player/ScrapingPart.tsx
··· 31 31 sources: Record<string, ScrapingSegment>, 32 32 sourceOrder: ScrapingItems[], 33 33 ) => void; 34 + startFromSourceId?: string; 34 35 } 35 36 36 37 export function ScrapingPart(props: ScrapingProps) { 37 38 const { report } = useReportProviders(); 38 - const { startScraping, sourceOrder, sources, currentSource } = useScrape(); 39 + const { startScraping, resumeScraping, sourceOrder, sources, currentSource } = 40 + useScrape(); 39 41 const isMounted = useMountedState(); 40 42 const { t } = useTranslation(); 41 43 ··· 60 62 }; 61 63 }, [sourceOrder, sources]); 62 64 63 - const started = useRef(false); 65 + const started = useRef<string | null>(null); 64 66 useEffect(() => { 65 - if (started.current) return; 66 - started.current = true; 67 + // Only start scraping if we haven't started with this startFromSourceId before 68 + const currentKey = props.startFromSourceId || "default"; 69 + if (started.current === currentKey) return; 70 + started.current = currentKey; 71 + 67 72 (async () => { 68 - const output = await startScraping(props.media); 73 + const output = props.startFromSourceId 74 + ? await resumeScraping(props.media, props.startFromSourceId) 75 + : await startScraping(props.media); 69 76 if (!isMounted()) return; 70 77 props.onResult?.( 71 78 resultRef.current.sources, ··· 80 87 ); 81 88 props.onGetStream?.(output); 82 89 })().catch(() => setFailedStartScrape(true)); 83 - }, [startScraping, props, report, isMounted]); 90 + }, [startScraping, resumeScraping, props, report, isMounted]); 84 91 85 92 let currentProviderIndex = sourceOrder.findIndex( 86 93 (s) => s.id === currentSource || s.children.includes(currentSource ?? ""),
+25
src/pages/parts/settings/PreferencesPart.tsx
··· 39 39 setManualSourceSelection: (v: boolean) => void; 40 40 enableDoubleClickToSeek: boolean; 41 41 setEnableDoubleClickToSeek: (v: boolean) => void; 42 + enableAutoResumeOnPlaybackError: boolean; 43 + setEnableAutoResumeOnPlaybackError: (v: boolean) => void; 42 44 }) { 43 45 const { t } = useTranslation(); 44 46 const sorted = sortLangCodes(appLanguageOptions.map((item) => item.code)); ··· 266 268 <Toggle enabled={props.manualSourceSelection} /> 267 269 <p className="flex-1 text-white font-bold"> 268 270 {t("settings.preferences.manualSourceLabel")} 271 + </p> 272 + </div> 273 + </div> 274 + 275 + {/* Auto Resume on Playback Error */} 276 + <div> 277 + <p className="text-white font-bold mb-3"> 278 + {t("settings.preferences.autoResumeOnPlaybackError")} 279 + </p> 280 + <p className="max-w-[25rem] font-medium"> 281 + {t("settings.preferences.autoResumeOnPlaybackErrorDescription")} 282 + </p> 283 + <div 284 + onClick={() => 285 + props.setEnableAutoResumeOnPlaybackError( 286 + !props.enableAutoResumeOnPlaybackError, 287 + ) 288 + } 289 + className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg" 290 + > 291 + <Toggle enabled={props.enableAutoResumeOnPlaybackError} /> 292 + <p className="flex-1 text-white font-bold"> 293 + {t("settings.preferences.autoResumeOnPlaybackErrorLabel")} 269 294 </p> 270 295 </div> 271 296 </div>
+8
src/stores/preferences/index.tsx
··· 30 30 homeSectionOrder: string[]; 31 31 manualSourceSelection: boolean; 32 32 enableDoubleClickToSeek: boolean; 33 + enableAutoResumeOnPlaybackError: boolean; 33 34 34 35 setEnableThumbnails(v: boolean): void; 35 36 setEnableAutoplay(v: boolean): void; ··· 58 59 setHomeSectionOrder(v: string[]): void; 59 60 setManualSourceSelection(v: boolean): void; 60 61 setEnableDoubleClickToSeek(v: boolean): void; 62 + setEnableAutoResumeOnPlaybackError(v: boolean): void; 61 63 } 62 64 63 65 export const usePreferencesStore = create( ··· 90 92 homeSectionOrder: ["watching", "bookmarks"], 91 93 manualSourceSelection: false, 92 94 enableDoubleClickToSeek: false, 95 + enableAutoResumeOnPlaybackError: true, 93 96 setEnableThumbnails(v) { 94 97 set((s) => { 95 98 s.enableThumbnails = v; ··· 228 231 setEnableDoubleClickToSeek(v) { 229 232 set((s) => { 230 233 s.enableDoubleClickToSeek = v; 234 + }); 235 + }, 236 + setEnableAutoResumeOnPlaybackError(v) { 237 + set((s) => { 238 + s.enableAutoResumeOnPlaybackError = v; 231 239 }); 232 240 }, 233 241 })),