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",