From 4ced25623ff69eb0718d0519a9c60f8a3a24096d Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 30 Nov 2025 17:28:41 -0700 Subject: [PATCH] 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. --- src/assets/locales/en.json | 5 ++ src/backend/accounts/settings.ts | 2 + src/hooks/useProviderScrape.tsx | 65 +++++++++++----- src/hooks/useSettingsState.ts | 16 +++- src/pages/PlayerView.tsx | 28 ++++++- src/pages/Settings.tsx | 23 +++++- src/pages/parts/player/PlaybackErrorPart.tsx | 82 ++++++++++++++++---- src/pages/parts/player/ScrapingPart.tsx | 19 +++-- src/pages/parts/settings/PreferencesPart.tsx | 25 ++++++ src/stores/preferences/index.tsx | 8 ++ 10 files changed, 233 insertions(+), 40 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 0ab32907..08e2b1b5 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -841,9 +841,11 @@ "errorNetwork": "Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.", "errorNotSupported": "The media or media provider object is not supported." }, + "autoResumeText": "There was an error trying to play the media 😖. Automatically trying the next source...", "copyDebugInfo": "Copy debug info", "debugInfo": "Check console for more details.", "homeButton": "Go home", + "resumeButton": "Try next source", "text": "There was an error trying to play the media 😖. Please try again or try a different source!", "title": "Failed to play video!" }, @@ -1135,6 +1137,9 @@ "manualSource": "Manual source selection", "manualSourceDescription": "Require picking a source before scraping. Disables automatic source selection and opens the source picker when starting playback.", "manualSourceLabel": "Manual source selection", + "autoResumeOnPlaybackError": "Auto resume on playback error", + "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.", + "autoResumeOnPlaybackErrorLabel": "Auto resume on playback error", "lastSuccessfulSource": "Last used source", "lastSuccessfulSourceDescription": "Automatically prioritize the source that successfully provided content for the previous episode. This helps ensure continuity when watching series.", "lastSuccessfulSourceEnableLabel": "Last used source" diff --git a/src/backend/accounts/settings.ts b/src/backend/accounts/settings.ts index f0f69b33..c38cf17f 100644 --- a/src/backend/accounts/settings.ts +++ b/src/backend/accounts/settings.ts @@ -35,6 +35,7 @@ export interface SettingsInput { homeSectionOrder?: string[] | null; manualSourceSelection?: boolean; enableDoubleClickToSeek?: boolean; + enableAutoResumeOnPlaybackError?: boolean; } export interface SettingsResponse { @@ -69,6 +70,7 @@ export interface SettingsResponse { homeSectionOrder?: string[] | null; manualSourceSelection?: boolean; enableDoubleClickToSeek?: boolean; + enableAutoResumeOnPlaybackError?: boolean; } export function updateSettings( diff --git a/src/hooks/useProviderScrape.tsx b/src/hooks/useProviderScrape.tsx index be0e73c9..6123dbc9 100644 --- a/src/hooks/useProviderScrape.tsx +++ b/src/hooks/useProviderScrape.tsx @@ -167,30 +167,53 @@ export function useScrape() { const disabledEmbeds = usePreferencesStore((s) => s.disabledEmbeds); const startScraping = useCallback( - async (media: ScrapeMedia) => { - // Create source order that prioritizes last successful source - let filteredSourceOrder = enableSourceOrder - ? preferredSourceOrder.filter((id) => !disabledSources.includes(id)) - : undefined; + async (media: ScrapeMedia, startFromSourceId?: string) => { + const providerInstance = getProviders(); + const allSources = providerInstance.listSources(); + + // Start with all available sources (filtered by disabled ones) + let baseSourceOrder = allSources + .filter((source) => !disabledSources.includes(source.id)) + .map((source) => source.id); + + // Apply custom source ordering if enabled + if (enableSourceOrder && preferredSourceOrder.length > 0) { + const orderedSources: string[] = []; + const remainingSources = [...baseSourceOrder]; + + // Add sources in preferred order + for (const sourceId of preferredSourceOrder) { + const sourceIndex = remainingSources.indexOf(sourceId); + if (sourceIndex !== -1) { + orderedSources.push(sourceId); + remainingSources.splice(sourceIndex, 1); + } + } + + // Add remaining sources + baseSourceOrder = [...orderedSources, ...remainingSources]; + } // If we have a last successful source and the feature is enabled, prioritize it if (enableLastSuccessfulSource && lastSuccessfulSource) { - // Get all available sources (either from custom order or default) - const availableSources = filteredSourceOrder || []; - - // If the last successful source is not disabled and exists in available sources, - // move it to the front - if ( - !disabledSources.includes(lastSuccessfulSource) && - availableSources.includes(lastSuccessfulSource) - ) { - filteredSourceOrder = [ + const lastSourceIndex = baseSourceOrder.indexOf(lastSuccessfulSource); + if (lastSourceIndex !== -1) { + baseSourceOrder = [ lastSuccessfulSource, - ...availableSources.filter((id) => id !== lastSuccessfulSource), + ...baseSourceOrder.filter((id) => id !== lastSuccessfulSource), ]; } } + // If starting from a specific source ID, filter the order to start AFTER that source + let filteredSourceOrder = baseSourceOrder; + if (startFromSourceId) { + const startIndex = filteredSourceOrder.indexOf(startFromSourceId); + if (startIndex !== -1) { + filteredSourceOrder = filteredSourceOrder.slice(startIndex + 1); + } + } + // Filter out disabled embeds from the embed order const filteredEmbedOrder = enableEmbedOrder ? preferredEmbedOrder.filter((id) => !disabledEmbeds.includes(id)) @@ -223,9 +246,7 @@ export function useScrape() { const providers = getProviders(); const output = await providers.runAll({ media, - // Only pass sourceOrder if enableSourceOrder is true, and filter out disabled sources sourceOrder: filteredSourceOrder, - // Only pass embedOrder if enableEmbedOrder is true embedOrder: filteredEmbedOrder, events: { init: initEvent, @@ -256,8 +277,16 @@ export function useScrape() { ], ); + const resumeScraping = useCallback( + async (media: ScrapeMedia, startFromSourceId: string) => { + return startScraping(media, startFromSourceId); + }, + [startScraping], + ); + return { startScraping, + resumeScraping, sourceOrder, sources, currentSource, diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts index 3ab198b0..b9e7dd37 100644 --- a/src/hooks/useSettingsState.ts +++ b/src/hooks/useSettingsState.ts @@ -79,6 +79,7 @@ export function useSettingsState( homeSectionOrder: string[], manualSourceSelection: boolean, enableDoubleClickToSeek: boolean, + enableAutoResumeOnPlaybackError: boolean, ) { const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] = useDerived(proxyUrls); @@ -262,6 +263,12 @@ export function useSettingsState( resetEnableDoubleClickToSeek, enableDoubleClickToSeekChanged, ] = useDerived(enableDoubleClickToSeek); + const [ + enableAutoResumeOnPlaybackErrorState, + setEnableAutoResumeOnPlaybackErrorState, + resetEnableAutoResumeOnPlaybackError, + enableAutoResumeOnPlaybackErrorChanged, + ] = useDerived(enableAutoResumeOnPlaybackError); function reset() { resetTheme(); @@ -299,6 +306,7 @@ export function useSettingsState( resetHomeSectionOrder(); resetManualSourceSelection(); resetEnableDoubleClickToSeek(); + resetEnableAutoResumeOnPlaybackError(); } const changed = @@ -336,7 +344,8 @@ export function useSettingsState( enableHoldToBoostChanged || homeSectionOrderChanged || manualSourceSelectionChanged || - enableDoubleClickToSeekChanged; + enableDoubleClickToSeekChanged || + enableAutoResumeOnPlaybackErrorChanged; return { reset, @@ -516,5 +525,10 @@ export function useSettingsState( set: setEnableDoubleClickToSeekState, changed: enableDoubleClickToSeekChanged, }, + enableAutoResumeOnPlaybackError: { + state: enableAutoResumeOnPlaybackErrorState, + set: setEnableAutoResumeOnPlaybackErrorState, + changed: enableAutoResumeOnPlaybackErrorChanged, + }, }; } diff --git a/src/pages/PlayerView.tsx b/src/pages/PlayerView.tsx index 13e4131d..f6734991 100644 --- a/src/pages/PlayerView.tsx +++ b/src/pages/PlayerView.tsx @@ -25,6 +25,7 @@ import { ScrapingPart } from "@/pages/parts/player/ScrapingPart"; import { SourceSelectPart } from "@/pages/parts/player/SourceSelectPart"; import { useLastNonPlayerLink } from "@/stores/history"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; +import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; import { getProgressPercentage, useProgressStore } from "@/stores/progress"; import { needsOnboarding } from "@/utils/onboarding"; @@ -41,6 +42,9 @@ export function RealPlayerView() { sources: Record; sourceOrder: ScrapingItems[]; } | null>(null); + const [resumeFromSourceId, setResumeFromSourceId] = useState( + null, + ); const [startAtParam] = useQueryParam("t"); const { status, @@ -51,6 +55,7 @@ export function RealPlayerView() { setShouldStartFromBeginning, setStatus, } = usePlayer(); + const sourceId = usePlayerStore((s) => s.sourceId); const { setPlayerMeta, scrapeMedia } = usePlayerMeta(); const backUrl = useLastNonPlayerLink(); const manualSourceSelection = usePreferencesStore( @@ -158,6 +163,18 @@ export function RealPlayerView() { setStatus(playerStatus.SCRAPING); }, [setShouldStartFromBeginning, setStatus]); + const handleResumeScraping = useCallback( + (startFromSourceId: string) => { + // Set resume source first + setResumeFromSourceId(startFromSourceId); + // Then change status in next tick to ensure re-render + setTimeout(() => { + setStatus(playerStatus.SCRAPING); + }, 0); + }, + [setStatus], + ); + const playAfterScrape = useCallback( (out: RunOutput | null) => { if (!out) return; @@ -198,13 +215,17 @@ export function RealPlayerView() { ) : ( { setErrorData({ sourceOrder, sources, }); setScrapeNotFound(); + // Clear resume state after scraping + setResumeFromSourceId(null); }} onGetStream={playAfterScrape} /> @@ -213,7 +234,12 @@ export function RealPlayerView() { {status === playerStatus.SCRAPE_NOT_FOUND && errorData ? ( ) : null} - {status === playerStatus.PLAYBACK_ERROR ? : null} + {status === playerStatus.PLAYBACK_ERROR ? ( + + ) : null} ); } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c4bb6f90..d0aaf9be 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -486,6 +486,13 @@ export function SettingsPage() { (s) => s.setEnableDoubleClickToSeek, ); + const enableAutoResumeOnPlaybackError = usePreferencesStore( + (s) => s.enableAutoResumeOnPlaybackError, + ); + const setEnableAutoResumeOnPlaybackError = usePreferencesStore( + (s) => s.setEnableAutoResumeOnPlaybackError, + ); + const account = useAuthStore((s) => s.account); const updateProfile = useAuthStore((s) => s.setAccountProfile); const updateDeviceName = useAuthStore((s) => s.updateDeviceName); @@ -557,6 +564,7 @@ export function SettingsPage() { homeSectionOrder, manualSourceSelection, enableDoubleClickToSeek, + enableAutoResumeOnPlaybackError, ); const availableSources = useMemo(() => { @@ -622,7 +630,8 @@ export function SettingsPage() { state.enableHoldToBoost.changed || state.homeSectionOrder.changed || state.manualSourceSelection.changed || - state.enableDoubleClickToSeek + state.enableDoubleClickToSeek.changed || + state.enableAutoResumeOnPlaybackError ) { await updateSettings(backendUrl, account, { applicationLanguage: state.appLanguage.state, @@ -651,6 +660,8 @@ export function SettingsPage() { homeSectionOrder: state.homeSectionOrder.state, manualSourceSelection: state.manualSourceSelection.state, enableDoubleClickToSeek: state.enableDoubleClickToSeek.state, + enableAutoResumeOnPlaybackError: + state.enableAutoResumeOnPlaybackError.state, }); } if (state.deviceName.changed) { @@ -705,6 +716,9 @@ export function SettingsPage() { setHomeSectionOrder(state.homeSectionOrder.state); setManualSourceSelection(state.manualSourceSelection.state); setEnableDoubleClickToSeek(state.enableDoubleClickToSeek.state); + setEnableAutoResumeOnPlaybackError( + state.enableAutoResumeOnPlaybackError.state, + ); if (state.profile.state) { updateProfile(state.profile.state); @@ -757,6 +771,7 @@ export function SettingsPage() { setHomeSectionOrder, setManualSourceSelection, setEnableDoubleClickToSeek, + setEnableAutoResumeOnPlaybackError, ]); return ( @@ -838,6 +853,12 @@ export function SettingsPage() { setManualSourceSelection={state.manualSourceSelection.set} enableDoubleClickToSeek={state.enableDoubleClickToSeek.state} setEnableDoubleClickToSeek={state.enableDoubleClickToSeek.set} + enableAutoResumeOnPlaybackError={ + state.enableAutoResumeOnPlaybackError.state + } + setEnableAutoResumeOnPlaybackError={ + state.enableAutoResumeOnPlaybackError.set + } /> )} diff --git a/src/pages/parts/player/PlaybackErrorPart.tsx b/src/pages/parts/player/PlaybackErrorPart.tsx index 975d1461..3561f727 100644 --- a/src/pages/parts/player/PlaybackErrorPart.tsx +++ b/src/pages/parts/player/PlaybackErrorPart.tsx @@ -14,26 +14,64 @@ import { usePreferencesStore } from "@/stores/preferences"; import { ErrorCardInModal } from "../errors/ErrorCard"; -export function PlaybackErrorPart() { +export interface PlaybackErrorPartProps { + onResume?: (startFromSourceId: string) => void; + currentSourceId?: string | null; +} + +export function PlaybackErrorPart(props: PlaybackErrorPartProps) { const { t } = useTranslation(); const playbackError = usePlayerStore((s) => s.interface.error); const modal = useModal("error"); const settingsRouter = useOverlayRouter("settings"); const hasOpenedSettings = useRef(false); + const hasAutoResumed = useRef(false); const setLastSuccessfulSource = usePreferencesStore( (s) => s.setLastSuccessfulSource, ); + const enableAutoResumeOnPlaybackError = usePreferencesStore( + (s) => s.enableAutoResumeOnPlaybackError, + ); - // Automatically open the settings overlay when a playback error occurs + // Automatically open the settings overlay when a playback error occurs (unless auto-resume is enabled) useEffect(() => { - if (playbackError && !hasOpenedSettings.current) { + if ( + playbackError && + !hasOpenedSettings.current && + !enableAutoResumeOnPlaybackError + ) { hasOpenedSettings.current = true; // Reset the last successful source when a playback error occurs setLastSuccessfulSource(null); settingsRouter.open(); settingsRouter.navigate("/source"); } - }, [playbackError, settingsRouter, setLastSuccessfulSource]); + }, [ + playbackError, + settingsRouter, + setLastSuccessfulSource, + enableAutoResumeOnPlaybackError, + ]); + + // Automatically resume scraping from the next source if enabled + useEffect(() => { + if ( + playbackError && + !hasAutoResumed.current && + enableAutoResumeOnPlaybackError && + props.currentSourceId && + props.onResume + ) { + hasAutoResumed.current = true; + // Immediately call resume without delay since we don't need the overlay + props.onResume!(props.currentSourceId!); + } + }, [ + playbackError, + enableAutoResumeOnPlaybackError, + props.currentSourceId, + props.onResume, + ]); const handleOpenSourcePicker = () => { settingsRouter.open(); @@ -45,7 +83,33 @@ export function PlaybackErrorPart() { {t("player.playbackError.badge")} {t("player.playbackError.title")} - {t("player.playbackError.text")} + + {enableAutoResumeOnPlaybackError + ? t("player.playbackError.autoResumeText") + : t("player.playbackError.text")} + +
+ {props.currentSourceId && + props.onResume && + !enableAutoResumeOnPlaybackError && ( + + )} + +
-
+ {/* Auto Resume on Playback Error */} +
+

+ {t("settings.preferences.autoResumeOnPlaybackError")} +

+

+ {t("settings.preferences.autoResumeOnPlaybackErrorDescription")} +

+
+ props.setEnableAutoResumeOnPlaybackError( + !props.enableAutoResumeOnPlaybackError, + ) + } + 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" + > + +

+ {t("settings.preferences.autoResumeOnPlaybackErrorLabel")} +

+
+
+ {/* Last Successful Source Preference */}

diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index b5ef6ff1..60b72d0d 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -30,6 +30,7 @@ export interface PreferencesStore { homeSectionOrder: string[]; manualSourceSelection: boolean; enableDoubleClickToSeek: boolean; + enableAutoResumeOnPlaybackError: boolean; setEnableThumbnails(v: boolean): void; setEnableAutoplay(v: boolean): void; @@ -58,6 +59,7 @@ export interface PreferencesStore { setHomeSectionOrder(v: string[]): void; setManualSourceSelection(v: boolean): void; setEnableDoubleClickToSeek(v: boolean): void; + setEnableAutoResumeOnPlaybackError(v: boolean): void; } export const usePreferencesStore = create( @@ -90,6 +92,7 @@ export const usePreferencesStore = create( homeSectionOrder: ["watching", "bookmarks"], manualSourceSelection: false, enableDoubleClickToSeek: false, + enableAutoResumeOnPlaybackError: true, setEnableThumbnails(v) { set((s) => { s.enableThumbnails = v; @@ -230,6 +233,11 @@ export const usePreferencesStore = create( s.enableDoubleClickToSeek = v; }); }, + setEnableAutoResumeOnPlaybackError(v) { + set((s) => { + s.enableAutoResumeOnPlaybackError = v; + }); + }, })), { name: "__MW::preferences",