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.
This commit is contained in:
Pas 2025-11-30 17:28:41 -07:00
parent 32f7178a1e
commit 4ced25623f
10 changed files with 233 additions and 40 deletions

View file

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

View file

@ -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(

View file

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

View file

@ -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,
},
};
}

View file

@ -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<string, ScrapingSegment>;
sourceOrder: ScrapingItems[];
} | null>(null);
const [resumeFromSourceId, setResumeFromSourceId] = useState<string | null>(
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() {
<SourceSelectPart media={scrapeMedia} />
) : (
<ScrapingPart
key={`scraping-${resumeFromSourceId || "default"}`}
media={scrapeMedia}
startFromSourceId={resumeFromSourceId || undefined}
onResult={(sources, sourceOrder) => {
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 ? (
<ScrapeErrorPart data={errorData} />
) : null}
{status === playerStatus.PLAYBACK_ERROR ? <PlaybackErrorPart /> : null}
{status === playerStatus.PLAYBACK_ERROR ? (
<PlaybackErrorPart
onResume={handleResumeScraping}
currentSourceId={sourceId}
/>
) : null}
</PlayerPart>
);
}

View file

@ -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 (
<SubPageLayout>
@ -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
}
/>
</div>
)}

View file

@ -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() {
<ErrorContainer>
<IconPill icon={Icons.WAND}>{t("player.playbackError.badge")}</IconPill>
<Title>{t("player.playbackError.title")}</Title>
<Paragraph>{t("player.playbackError.text")}</Paragraph>
<Paragraph>
{enableAutoResumeOnPlaybackError
? t("player.playbackError.autoResumeText")
: t("player.playbackError.text")}
</Paragraph>
<div className="flex gap-3">
{props.currentSourceId &&
props.onResume &&
!enableAutoResumeOnPlaybackError && (
<Button
onClick={() => props.onResume!(props.currentSourceId!)}
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.playbackError.resumeButton")}
</Button>
)}
<Button
onClick={handleOpenSourcePicker}
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.menus.sources.title")}
</Button>
</div>
<div className="flex gap-3">
<Button
onClick={() => modal.show()}
@ -55,14 +119,6 @@ export function PlaybackErrorPart() {
>
{t("errors.showError")}
</Button>
<Button
onClick={handleOpenSourcePicker}
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.menus.sources.title")}
</Button>
</div>
<div className="flex gap-3">
<Button

View file

@ -31,11 +31,13 @@ export interface ScrapingProps {
sources: Record<string, ScrapingSegment>,
sourceOrder: ScrapingItems[],
) => void;
startFromSourceId?: string;
}
export function ScrapingPart(props: ScrapingProps) {
const { report } = useReportProviders();
const { startScraping, sourceOrder, sources, currentSource } = useScrape();
const { startScraping, resumeScraping, sourceOrder, sources, currentSource } =
useScrape();
const isMounted = useMountedState();
const { t } = useTranslation();
@ -60,12 +62,17 @@ export function ScrapingPart(props: ScrapingProps) {
};
}, [sourceOrder, sources]);
const started = useRef(false);
const started = useRef<string | null>(null);
useEffect(() => {
if (started.current) return;
started.current = true;
// Only start scraping if we haven't started with this startFromSourceId before
const currentKey = props.startFromSourceId || "default";
if (started.current === currentKey) return;
started.current = currentKey;
(async () => {
const output = await startScraping(props.media);
const output = props.startFromSourceId
? await resumeScraping(props.media, props.startFromSourceId)
: await startScraping(props.media);
if (!isMounted()) return;
props.onResult?.(
resultRef.current.sources,
@ -80,7 +87,7 @@ export function ScrapingPart(props: ScrapingProps) {
);
props.onGetStream?.(output);
})().catch(() => setFailedStartScrape(true));
}, [startScraping, props, report, isMounted]);
}, [startScraping, resumeScraping, props, report, isMounted]);
let currentProviderIndex = sourceOrder.findIndex(
(s) => s.id === currentSource || s.children.includes(currentSource ?? ""),

View file

@ -39,6 +39,8 @@ export function PreferencesPart(props: {
setManualSourceSelection: (v: boolean) => void;
enableDoubleClickToSeek: boolean;
setEnableDoubleClickToSeek: (v: boolean) => void;
enableAutoResumeOnPlaybackError: boolean;
setEnableAutoResumeOnPlaybackError: (v: boolean) => void;
}) {
const { t } = useTranslation();
const sorted = sortLangCodes(appLanguageOptions.map((item) => item.code));
@ -270,6 +272,29 @@ export function PreferencesPart(props: {
</div>
</div>
{/* Auto Resume on Playback Error */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.preferences.autoResumeOnPlaybackError")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.preferences.autoResumeOnPlaybackErrorDescription")}
</p>
<div
onClick={() =>
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"
>
<Toggle enabled={props.enableAutoResumeOnPlaybackError} />
<p className="flex-1 text-white font-bold">
{t("settings.preferences.autoResumeOnPlaybackErrorLabel")}
</p>
</div>
</div>
{/* Last Successful Source Preference */}
<div>
<p className="text-white font-bold mb-3">

View file

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