From 627d66eced7087e66d9e12be336cdecc2a4606eb Mon Sep 17 00:00:00 2001
From: Pas <74743263+Pasithea0@users.noreply.github.com>
Date: Sun, 2 Nov 2025 21:54:45 -0700
Subject: [PATCH 01/70] add paste and copy subtitle options
---
src/assets/locales/en.json | 2 +
.../player/atoms/settings/CaptionsView.tsx | 144 +++++++++++++++---
.../player/internals/ContextMenu/Links.tsx | 4 +
3 files changed, 125 insertions(+), 25 deletions(-)
diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json
index b2ccaeb2..f9fd3941 100644
--- a/src/assets/locales/en.json
+++ b/src/assets/locales/en.json
@@ -736,6 +736,8 @@
},
"subtitles": {
"customChoice": "Drop or upload file",
+ "pasteChoice": "Paste subtitle data",
+ "doubleClickToCopy": "Double click to copy subtitle data",
"customizeLabel": "Customize",
"previewLabel": "Subtitle preview:",
"offChoice": "Off",
diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx
index 5eefe195..2736b036 100644
--- a/src/components/player/atoms/settings/CaptionsView.tsx
+++ b/src/components/player/atoms/settings/CaptionsView.tsx
@@ -40,9 +40,11 @@ export function CaptionOption(props: {
subtitleSource?: string;
subtitleEncoding?: string;
isHearingImpaired?: boolean;
+ onDoubleClick?: () => void;
}) {
const [showTooltip, setShowTooltip] = useState(false);
const tooltipTimeoutRef = useRef(null);
+ const { t } = useTranslation();
const tooltipContent = useMemo(() => {
if (!props.subtitleUrl && !props.subtitleSource) return null;
@@ -107,6 +109,7 @@ export function CaptionOption(props: {
loading={props.loading}
error={props.error}
onClick={props.onClick}
+ onDoubleClick={props.onDoubleClick}
>
{tooltipContent && showTooltip && (
-
+
{tooltipContent}
+ {props.onDoubleClick && (
+
+ {t("player.menus.subtitles.doubleClickToCopy")}
+
+ )}
)}
@@ -219,6 +227,63 @@ export function CustomCaptionOption() {
);
}
+export function PasteCaptionOption() {
+ const { t } = useTranslation();
+ const setCaption = usePlayerStore((s) => s.setCaption);
+ const setCustomSubs = useSubtitleStore((s) => s.setCustomSubs);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handlePaste = async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const clipboardText = await navigator.clipboard.readText();
+ const parsedData = JSON.parse(clipboardText);
+
+ // Validate the structure
+ if (!parsedData.id || !parsedData.url || !parsedData.language) {
+ throw new Error("Invalid subtitle data format");
+ }
+
+ // Check for CORS restrictions
+ if (parsedData.hasCorsRestrictions) {
+ throw new Error("Protected subtitle url, cannot be used");
+ }
+
+ // Fetch the subtitle content
+ const response = await fetch(parsedData.url);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch subtitle: ${response.status}`);
+ }
+
+ const subtitleText = await response.text();
+
+ // Convert to SRT format
+ const converted = convert(subtitleText, "srt");
+
+ setCaption({
+ language: parsedData.language,
+ srtData: converted,
+ id: parsedData.id,
+ });
+ setCustomSubs();
+ } catch (err) {
+ console.error("Failed to paste subtitle:", err);
+ setError(err instanceof Error ? err.message : "Failed to paste subtitle");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ {t("player.menus.subtitles.pasteChoice")}
+
+ );
+}
+
export function CaptionsView({
id,
backLink,
@@ -315,28 +380,54 @@ export function CaptionsView({
// Render subtitle option
const renderSubtitleOption = (
v: CaptionListItem & { languageName: string },
- ) => (
- {
+ const handleDoubleClick = async () => {
+ const copyData = {
+ id: v.id,
+ url: v.url,
+ language: v.language,
+ type: v.type,
+ hasCorsRestrictions: v.needsProxy,
+ opensubtitles: v.opensubtitles,
+ display: v.display,
+ media: v.media,
+ isHearingImpaired: v.isHearingImpaired,
+ source: v.source,
+ encoding: v.encoding,
+ };
+
+ try {
+ await navigator.clipboard.writeText(JSON.stringify(copyData, null, 2));
+ // Could add a toast notification here if needed
+ } catch (err) {
+ console.error("Failed to copy subtitle data:", err);
}
- onClick={() => startDownload(v.id)}
- flag
- subtitleUrl={v.url}
- subtitleType={v.type}
- subtitleSource={v.source}
- subtitleEncoding={v.encoding}
- isHearingImpaired={v.isHearingImpaired}
- >
- {v.languageName}
-
- );
+ };
+
+ return (
+ startDownload(v.id)}
+ onDoubleClick={handleDoubleClick}
+ flag
+ subtitleUrl={v.url}
+ subtitleType={v.type}
+ subtitleSource={v.source}
+ subtitleEncoding={v.encoding}
+ isHearingImpaired={v.isHearingImpaired}
+ >
+ {v.languageName}
+
+ );
+ };
return (
<>
@@ -431,11 +522,14 @@ export function CaptionsView({
{/* Custom upload option */}
+ {/* Paste subtitle option */}
+
+
+
+
{/* Search input */}
{(sourceCaptions.length || externalCaptions.length) > 0 && (
-
-
-
+
)}
{/* No subtitles available message */}
diff --git a/src/components/player/internals/ContextMenu/Links.tsx b/src/components/player/internals/ContextMenu/Links.tsx
index caed159c..7d53e264 100644
--- a/src/components/player/internals/ContextMenu/Links.tsx
+++ b/src/components/player/internals/ContextMenu/Links.tsx
@@ -80,6 +80,7 @@ export function Link(props: {
clickable?: boolean;
active?: boolean;
onClick?: () => void;
+ onDoubleClick?: () => void;
children?: ReactNode;
className?: string;
box?: boolean;
@@ -126,6 +127,7 @@ export function Link(props: {
className={classes}
style={props.box ? {} : styles}
onClick={props.onClick}
+ onDoubleClick={props.onDoubleClick}
data-active-link={props.active ? true : undefined}
disabled={props.disabled}
>
@@ -162,6 +164,7 @@ export function SelectableLink(props: {
selected?: boolean;
loading?: boolean;
onClick?: () => void;
+ onDoubleClick?: () => void;
children?: ReactNode;
disabled?: boolean;
error?: ReactNode;
@@ -187,6 +190,7 @@ export function SelectableLink(props: {
return (
Date: Sun, 2 Nov 2025 22:10:48 -0700
Subject: [PATCH 02/70] add lastSuccessfulSource feature
to sort the last successful source to the top
---
src/assets/locales/en.json | 5 ++-
src/backend/accounts/settings.ts | 4 ++
.../player/atoms/NextEpisodeButton.tsx | 12 ++++++
.../atoms/settings/SourceSelectingView.tsx | 38 ++++++++++++++++++-
.../player/internals/SkipEpisodeButton.tsx | 11 +++++-
src/hooks/auth/useAuthData.ts | 16 ++++++++
src/hooks/useProviderScrape.tsx | 30 ++++++++++++++-
src/hooks/useSettingsState.ts | 28 ++++++++++++++
src/pages/PlayerView.tsx | 10 +++++
src/pages/Settings.tsx | 26 +++++++++++++
src/pages/parts/player/SourceSelectPart.tsx | 37 +++++++++++++++++-
src/pages/parts/settings/PreferencesPart.tsx | 26 +++++++++++++
src/stores/preferences/index.tsx | 16 ++++++++
13 files changed, 251 insertions(+), 8 deletions(-)
diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json
index f9fd3941..de33a8e3 100644
--- a/src/assets/locales/en.json
+++ b/src/assets/locales/en.json
@@ -1107,7 +1107,10 @@
"embedOrderEnableLabel": "Custom embed order",
"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"
+ "manualSourceLabel": "Manual source selection",
+ "lastSuccessfulSource": "Last successful source",
+ "lastSuccessfulSourceDescription": "Automatically prioritize the source that successfully provided content for the previous episode. This helps ensure continuity when watching series.",
+ "lastSuccessfulSourceEnableLabel": "Enable last successful source"
},
"reset": "Reset",
"save": "Save",
diff --git a/src/backend/accounts/settings.ts b/src/backend/accounts/settings.ts
index 1460aa9c..14f9ca67 100644
--- a/src/backend/accounts/settings.ts
+++ b/src/backend/accounts/settings.ts
@@ -21,6 +21,8 @@ export interface SettingsInput {
forceCompactEpisodeView?: boolean;
sourceOrder?: string[] | null;
enableSourceOrder?: boolean;
+ lastSuccessfulSource?: string | null;
+ enableLastSuccessfulSource?: boolean;
disabledSources?: string[] | null;
embedOrder?: string[] | null;
enableEmbedOrder?: boolean;
@@ -52,6 +54,8 @@ export interface SettingsResponse {
forceCompactEpisodeView?: boolean;
sourceOrder?: string[] | null;
enableSourceOrder?: boolean;
+ lastSuccessfulSource?: string | null;
+ enableLastSuccessfulSource?: boolean;
disabledSources?: string[] | null;
embedOrder?: string[] | null;
enableEmbedOrder?: boolean;
diff --git a/src/components/player/atoms/NextEpisodeButton.tsx b/src/components/player/atoms/NextEpisodeButton.tsx
index 86e082c5..a59d2efb 100644
--- a/src/components/player/atoms/NextEpisodeButton.tsx
+++ b/src/components/player/atoms/NextEpisodeButton.tsx
@@ -106,12 +106,16 @@ export function NextEpisodeButton(props: {
const time = usePlayerStore((s) => s.progress.time);
const enableAutoplay = usePreferencesStore((s) => s.enableAutoplay);
const enableSkipCredits = usePreferencesStore((s) => s.enableSkipCredits);
+ const setLastSuccessfulSource = usePreferencesStore(
+ (s) => s.setLastSuccessfulSource,
+ );
const showingState = shouldShowNextEpisodeButton(time, duration);
const status = usePlayerStore((s) => s.status);
const setShouldStartFromBeginning = usePlayerStore(
(s) => s.setShouldStartFromBeginning,
);
const updateItem = useProgressStore((s) => s.updateItem);
+ const sourceId = usePlayerStore((s) => s.sourceId);
const isLastEpisode =
!meta?.episode?.number || !meta?.episodes?.at(-1)?.number
@@ -147,6 +151,12 @@ export function NextEpisodeButton(props: {
const loadNextEpisode = useCallback(() => {
if (!meta || !nextEp) return;
+
+ // Store the current source as the last successful source
+ if (sourceId) {
+ setLastSuccessfulSource(sourceId);
+ }
+
const metaCopy = { ...meta };
metaCopy.episode = nextEp;
metaCopy.season =
@@ -173,6 +183,8 @@ export function NextEpisodeButton(props: {
updateItem,
isLastEpisode,
nextSeason,
+ sourceId,
+ setLastSuccessfulSource,
]);
const startCurrentEpisodeFromBeginning = useCallback(() => {
diff --git a/src/components/player/atoms/settings/SourceSelectingView.tsx b/src/components/player/atoms/settings/SourceSelectingView.tsx
index c9ec789e..344c87d3 100644
--- a/src/components/player/atoms/settings/SourceSelectingView.tsx
+++ b/src/components/player/atoms/settings/SourceSelectingView.tsx
@@ -144,6 +144,12 @@ export function SourceSelectionView({
const currentSourceId = usePlayerStore((s) => s.sourceId);
const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder);
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
+ const lastSuccessfulSource = usePreferencesStore(
+ (s) => s.lastSuccessfulSource,
+ );
+ const enableLastSuccessfulSource = usePreferencesStore(
+ (s) => s.enableLastSuccessfulSource,
+ );
const disabledSources = usePreferencesStore((s) => s.disabledSources);
const sources = useMemo(() => {
@@ -154,13 +160,34 @@ export function SourceSelectionView({
.filter((v) => !disabledSources.includes(v.id));
if (!enableSourceOrder || preferredSourceOrder.length === 0) {
+ // Even without custom source order, prioritize last successful source if enabled
+ if (enableLastSuccessfulSource && lastSuccessfulSource) {
+ const lastSourceIndex = allSources.findIndex(
+ (s) => s.id === lastSuccessfulSource,
+ );
+ if (lastSourceIndex !== -1) {
+ const lastSource = allSources.splice(lastSourceIndex, 1)[0];
+ return [lastSource, ...allSources];
+ }
+ }
return allSources;
}
- // Sort sources according to preferred order
+ // Sort sources according to preferred order, but prioritize last successful source
const orderedSources = [];
const remainingSources = [...allSources];
+ // First, add the last successful source if it exists, is available, and the feature is enabled
+ if (enableLastSuccessfulSource && lastSuccessfulSource) {
+ const lastSourceIndex = remainingSources.findIndex(
+ (s) => s.id === lastSuccessfulSource,
+ );
+ if (lastSourceIndex !== -1) {
+ orderedSources.push(remainingSources[lastSourceIndex]);
+ remainingSources.splice(lastSourceIndex, 1);
+ }
+ }
+
// Add sources in preferred order
for (const sourceId of preferredSourceOrder) {
const sourceIndex = remainingSources.findIndex((s) => s.id === sourceId);
@@ -174,7 +201,14 @@ export function SourceSelectionView({
orderedSources.push(...remainingSources);
return orderedSources;
- }, [metaType, preferredSourceOrder, enableSourceOrder, disabledSources]);
+ }, [
+ metaType,
+ preferredSourceOrder,
+ enableSourceOrder,
+ disabledSources,
+ lastSuccessfulSource,
+ enableLastSuccessfulSource,
+ ]);
return (
<>
diff --git a/src/components/player/internals/SkipEpisodeButton.tsx b/src/components/player/internals/SkipEpisodeButton.tsx
index f02208ad..2cf04aa6 100644
--- a/src/components/player/internals/SkipEpisodeButton.tsx
+++ b/src/components/player/internals/SkipEpisodeButton.tsx
@@ -5,6 +5,7 @@ import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
import { VideoPlayerButton } from "@/components/player/internals/Button";
import { PlayerMeta } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
+import { usePreferencesStore } from "@/stores/preferences";
import { useProgressStore } from "@/stores/progress";
interface SkipEpisodeButtonProps {
@@ -19,13 +20,19 @@ export function SkipEpisodeButton(props: SkipEpisodeButtonProps) {
(s) => s.setShouldStartFromBeginning,
);
const updateItem = useProgressStore((s) => s.updateItem);
-
+ const sourceId = usePlayerStore((s) => s.sourceId);
+ const setLastSuccessfulSource = usePreferencesStore(
+ (s) => s.setLastSuccessfulSource,
+ );
const nextEp = meta?.episodes?.find(
(v) => v.number === (meta?.episode?.number ?? 0) + 1,
);
const loadNextEpisode = useCallback(() => {
if (!meta || !nextEp) return;
+ if (sourceId) {
+ setLastSuccessfulSource(sourceId);
+ }
const metaCopy = { ...meta };
metaCopy.episode = nextEp;
setShouldStartFromBeginning(true);
@@ -43,6 +50,8 @@ export function SkipEpisodeButton(props: SkipEpisodeButtonProps) {
props,
setShouldStartFromBeginning,
updateItem,
+ sourceId,
+ setLastSuccessfulSource,
]);
// Don't show button if not in control, not a show, or no next episode
diff --git a/src/hooks/auth/useAuthData.ts b/src/hooks/auth/useAuthData.ts
index 38339071..68cc2ac8 100644
--- a/src/hooks/auth/useAuthData.ts
+++ b/src/hooks/auth/useAuthData.ts
@@ -58,6 +58,12 @@ export function useAuthData() {
const setEnableSourceOrder = usePreferencesStore(
(s) => s.setEnableSourceOrder,
);
+ const setLastSuccessfulSource = usePreferencesStore(
+ (s) => s.setLastSuccessfulSource,
+ );
+ const setEnableLastSuccessfulSource = usePreferencesStore(
+ (s) => s.setEnableLastSuccessfulSource,
+ );
const setDisabledSources = usePreferencesStore((s) => s.setDisabledSources);
const setEmbedOrder = usePreferencesStore((s) => s.setEmbedOrder);
const setEnableEmbedOrder = usePreferencesStore((s) => s.setEnableEmbedOrder);
@@ -193,6 +199,14 @@ export function useAuthData() {
setEnableSourceOrder(settings.enableSourceOrder);
}
+ if (settings.lastSuccessfulSource !== undefined) {
+ setLastSuccessfulSource(settings.lastSuccessfulSource);
+ }
+
+ if (settings.enableLastSuccessfulSource !== undefined) {
+ setEnableLastSuccessfulSource(settings.enableLastSuccessfulSource);
+ }
+
if (settings.disabledSources !== undefined) {
setDisabledSources(settings.disabledSources ?? []);
}
@@ -265,6 +279,8 @@ export function useAuthData() {
setForceCompactEpisodeView,
setSourceOrder,
setEnableSourceOrder,
+ setLastSuccessfulSource,
+ setEnableLastSuccessfulSource,
setDisabledSources,
setEmbedOrder,
setEnableEmbedOrder,
diff --git a/src/hooks/useProviderScrape.tsx b/src/hooks/useProviderScrape.tsx
index 5ecca8f1..be0e73c9 100644
--- a/src/hooks/useProviderScrape.tsx
+++ b/src/hooks/useProviderScrape.tsx
@@ -155,6 +155,12 @@ export function useScrape() {
const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder);
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
+ const lastSuccessfulSource = usePreferencesStore(
+ (s) => s.lastSuccessfulSource,
+ );
+ const enableLastSuccessfulSource = usePreferencesStore(
+ (s) => s.enableLastSuccessfulSource,
+ );
const disabledSources = usePreferencesStore((s) => s.disabledSources);
const preferredEmbedOrder = usePreferencesStore((s) => s.embedOrder);
const enableEmbedOrder = usePreferencesStore((s) => s.enableEmbedOrder);
@@ -162,11 +168,29 @@ export function useScrape() {
const startScraping = useCallback(
async (media: ScrapeMedia) => {
- // Filter out disabled sources from the source order
- const filteredSourceOrder = enableSourceOrder
+ // Create source order that prioritizes last successful source
+ let filteredSourceOrder = enableSourceOrder
? preferredSourceOrder.filter((id) => !disabledSources.includes(id))
: undefined;
+ // 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 = [
+ lastSuccessfulSource,
+ ...availableSources.filter((id) => id !== lastSuccessfulSource),
+ ];
+ }
+ }
+
// Filter out disabled embeds from the embed order
const filteredEmbedOrder = enableEmbedOrder
? preferredEmbedOrder.filter((id) => !disabledEmbeds.includes(id))
@@ -223,6 +247,8 @@ export function useScrape() {
startScrape,
preferredSourceOrder,
enableSourceOrder,
+ lastSuccessfulSource,
+ enableLastSuccessfulSource,
disabledSources,
preferredEmbedOrder,
enableEmbedOrder,
diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts
index 33be4592..48fc0c61 100644
--- a/src/hooks/useSettingsState.ts
+++ b/src/hooks/useSettingsState.ts
@@ -60,6 +60,8 @@ export function useSettingsState(
enableDetailsModal: boolean,
sourceOrder: string[],
enableSourceOrder: boolean,
+ lastSuccessfulSource: string | null,
+ enableLastSuccessfulSource: boolean,
disabledSources: string[],
embedOrder: string[],
enableEmbedOrder: boolean,
@@ -164,6 +166,18 @@ export function useSettingsState(
resetEnableSourceOrder,
enableSourceOrderChanged,
] = useDerived(enableSourceOrder);
+ const [
+ lastSuccessfulSourceState,
+ setLastSuccessfulSourceState,
+ resetLastSuccessfulSource,
+ lastSuccessfulSourceChanged,
+ ] = useDerived(lastSuccessfulSource);
+ const [
+ enableLastSuccessfulSourceState,
+ setEnableLastSuccessfulSourceState,
+ resetEnableLastSuccessfulSource,
+ enableLastSuccessfulSourceChanged,
+ ] = useDerived(enableLastSuccessfulSource);
const [
disabledSourcesState,
setDisabledSourcesState,
@@ -259,6 +273,8 @@ export function useSettingsState(
resetEnableImageLogos();
resetSourceOrder();
resetEnableSourceOrder();
+ resetLastSuccessfulSource();
+ resetEnableLastSuccessfulSource();
resetDisabledSources();
resetEmbedOrder();
resetEnableEmbedOrder();
@@ -293,6 +309,8 @@ export function useSettingsState(
enableImageLogosChanged ||
sourceOrderChanged ||
enableSourceOrderChanged ||
+ lastSuccessfulSourceChanged ||
+ enableLastSuccessfulSourceChanged ||
disabledSourcesChanged ||
embedOrderChanged ||
enableEmbedOrderChanged ||
@@ -400,6 +418,16 @@ export function useSettingsState(
set: setEnableSourceOrderState,
changed: enableSourceOrderChanged,
},
+ lastSuccessfulSource: {
+ state: lastSuccessfulSourceState,
+ set: setLastSuccessfulSourceState,
+ changed: lastSuccessfulSourceChanged,
+ },
+ enableLastSuccessfulSource: {
+ state: enableLastSuccessfulSourceState,
+ set: setEnableLastSuccessfulSourceState,
+ changed: enableLastSuccessfulSourceChanged,
+ },
proxyTmdb: {
state: proxyTmdbState,
set: setProxyTmdbState,
diff --git a/src/pages/PlayerView.tsx b/src/pages/PlayerView.tsx
index 4dc69a71..13e4131d 100644
--- a/src/pages/PlayerView.tsx
+++ b/src/pages/PlayerView.tsx
@@ -56,10 +56,20 @@ export function RealPlayerView() {
const manualSourceSelection = usePreferencesStore(
(s) => s.manualSourceSelection,
);
+ const setLastSuccessfulSource = usePreferencesStore(
+ (s) => s.setLastSuccessfulSource,
+ );
const router = useOverlayRouter("settings");
const openedWatchPartyRef = useRef(false);
const progressItems = useProgressStore((s) => s.items);
+ // Reset last successful source when leaving the player
+ useEffect(() => {
+ return () => {
+ setLastSuccessfulSource(null);
+ };
+ }, [setLastSuccessfulSource]);
+
const paramsData = JSON.stringify({
media: params.media,
season: params.season,
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index c590282f..4f209711 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -282,6 +282,20 @@ export function SettingsPage() {
(s) => s.setEnableSourceOrder,
);
+ const lastSuccessfulSource = usePreferencesStore(
+ (s) => s.lastSuccessfulSource,
+ );
+ const setLastSuccessfulSource = usePreferencesStore(
+ (s) => s.setLastSuccessfulSource,
+ );
+
+ const enableLastSuccessfulSource = usePreferencesStore(
+ (s) => s.enableLastSuccessfulSource,
+ );
+ const setEnableLastSuccessfulSource = usePreferencesStore(
+ (s) => s.setEnableLastSuccessfulSource,
+ );
+
const disabledSources = usePreferencesStore((s) => s.disabledSources);
const setDisabledSources = usePreferencesStore((s) => s.setDisabledSources);
@@ -406,6 +420,8 @@ export function SettingsPage() {
enableDetailsModal,
sourceOrder,
enableSourceOrder,
+ lastSuccessfulSource,
+ enableLastSuccessfulSource,
disabledSources,
embedOrder,
enableEmbedOrder,
@@ -475,6 +491,8 @@ export function SettingsPage() {
state.enableImageLogos.changed ||
state.sourceOrder.changed ||
state.enableSourceOrder.changed ||
+ state.lastSuccessfulSource.changed ||
+ state.enableLastSuccessfulSource.changed ||
state.disabledSources.changed ||
state.proxyTmdb.changed ||
state.enableCarouselView.changed ||
@@ -500,6 +518,8 @@ export function SettingsPage() {
enableImageLogos: state.enableImageLogos.state,
sourceOrder: state.sourceOrder.state,
enableSourceOrder: state.enableSourceOrder.state,
+ lastSuccessfulSource: state.lastSuccessfulSource.state,
+ enableLastSuccessfulSource: state.enableLastSuccessfulSource.state,
disabledSources: state.disabledSources.state,
proxyTmdb: state.proxyTmdb.state,
enableCarouselView: state.enableCarouselView.state,
@@ -537,6 +557,8 @@ export function SettingsPage() {
setEnableImageLogos(state.enableImageLogos.state);
setSourceOrder(state.sourceOrder.state);
setEnableSourceOrder(state.enableSourceOrder.state);
+ setLastSuccessfulSource(state.lastSuccessfulSource.state);
+ setEnableLastSuccessfulSource(state.enableLastSuccessfulSource.state);
setDisabledSources(state.disabledSources.state);
setAppLanguage(state.appLanguage.state);
setTheme(state.theme.state);
@@ -584,6 +606,8 @@ export function SettingsPage() {
setEnableImageLogos,
setSourceOrder,
setEnableSourceOrder,
+ setLastSuccessfulSource,
+ setEnableLastSuccessfulSource,
setDisabledSources,
setAppLanguage,
setTheme,
@@ -650,6 +674,8 @@ export function SettingsPage() {
setSourceOrder={state.sourceOrder.set}
enableSourceOrder={state.enableSourceOrder.state}
setenableSourceOrder={state.enableSourceOrder.set}
+ enableLastSuccessfulSource={state.enableLastSuccessfulSource.state}
+ setEnableLastSuccessfulSource={state.enableLastSuccessfulSource.set}
disabledSources={state.disabledSources.state}
setDisabledSources={state.disabledSources.set}
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
diff --git a/src/pages/parts/player/SourceSelectPart.tsx b/src/pages/parts/player/SourceSelectPart.tsx
index cd371b7d..58198ad1 100644
--- a/src/pages/parts/player/SourceSelectPart.tsx
+++ b/src/pages/parts/player/SourceSelectPart.tsx
@@ -129,6 +129,12 @@ export function SourceSelectPart(props: { media: ScrapeMedia }) {
const routerId = "manualSourceSelect";
const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder);
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
+ const lastSuccessfulSource = usePreferencesStore(
+ (s) => s.lastSuccessfulSource,
+ );
+ const enableLastSuccessfulSource = usePreferencesStore(
+ (s) => s.enableLastSuccessfulSource,
+ );
const sources = useMemo(() => {
const metaType = props.media.type;
@@ -138,13 +144,34 @@ export function SourceSelectPart(props: { media: ScrapeMedia }) {
.filter((v) => v.mediaTypes?.includes(metaType));
if (!enableSourceOrder || preferredSourceOrder.length === 0) {
+ // Even without custom source order, prioritize last successful source if enabled
+ if (enableLastSuccessfulSource && lastSuccessfulSource) {
+ const lastSourceIndex = allSources.findIndex(
+ (s) => s.id === lastSuccessfulSource,
+ );
+ if (lastSourceIndex !== -1) {
+ const lastSource = allSources.splice(lastSourceIndex, 1)[0];
+ return [lastSource, ...allSources];
+ }
+ }
return allSources;
}
- // Sort sources according to preferred order
+ // Sort sources according to preferred order, but prioritize last successful source
const orderedSources = [];
const remainingSources = [...allSources];
+ // First, add the last successful source if it exists, is available, and the feature is enabled
+ if (enableLastSuccessfulSource && lastSuccessfulSource) {
+ const lastSourceIndex = remainingSources.findIndex(
+ (s) => s.id === lastSuccessfulSource,
+ );
+ if (lastSourceIndex !== -1) {
+ orderedSources.push(remainingSources[lastSourceIndex]);
+ remainingSources.splice(lastSourceIndex, 1);
+ }
+ }
+
// Add sources in preferred order
for (const sourceId of preferredSourceOrder) {
const sourceIndex = remainingSources.findIndex((s) => s.id === sourceId);
@@ -158,7 +185,13 @@ export function SourceSelectPart(props: { media: ScrapeMedia }) {
orderedSources.push(...remainingSources);
return orderedSources;
- }, [props.media.type, preferredSourceOrder, enableSourceOrder]);
+ }, [
+ props.media.type,
+ preferredSourceOrder,
+ enableSourceOrder,
+ lastSuccessfulSource,
+ enableLastSuccessfulSource,
+ ]);
if (selectedSourceId) {
return (
diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx
index 98e009fc..ed5575f2 100644
--- a/src/pages/parts/settings/PreferencesPart.tsx
+++ b/src/pages/parts/settings/PreferencesPart.tsx
@@ -27,6 +27,8 @@ export function PreferencesPart(props: {
setSourceOrder: (v: string[]) => void;
enableSourceOrder: boolean;
setenableSourceOrder: (v: boolean) => void;
+ enableLastSuccessfulSource: boolean;
+ setEnableLastSuccessfulSource: (v: boolean) => void;
disabledSources: string[];
setDisabledSources: (v: string[]) => void;
enableLowPerformanceMode: boolean;
@@ -267,6 +269,30 @@ export function PreferencesPart(props: {
+
+ {/* Last Successful Source Preference */}
+
+
+ {t("settings.preferences.lastSuccessfulSource")}
+
+
+ {t("settings.preferences.lastSuccessfulSourceDescription")}
+
+
+ props.setEnableLastSuccessfulSource(
+ !props.enableLastSuccessfulSource,
+ )
+ }
+ 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.lastSuccessfulSourceEnableLabel")}
+
+
+
+
{t("settings.preferences.sourceOrder")}
diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx
index 256227ed..59a1f2aa 100644
--- a/src/stores/preferences/index.tsx
+++ b/src/stores/preferences/index.tsx
@@ -14,6 +14,8 @@ export interface PreferencesStore {
forceCompactEpisodeView: boolean;
sourceOrder: string[];
enableSourceOrder: boolean;
+ lastSuccessfulSource: string | null;
+ enableLastSuccessfulSource: boolean;
disabledSources: string[];
embedOrder: string[];
enableEmbedOrder: boolean;
@@ -39,6 +41,8 @@ export interface PreferencesStore {
setForceCompactEpisodeView(v: boolean): void;
setSourceOrder(v: string[]): void;
setEnableSourceOrder(v: boolean): void;
+ setLastSuccessfulSource(v: string | null): void;
+ setEnableLastSuccessfulSource(v: boolean): void;
setDisabledSources(v: string[]): void;
setEmbedOrder(v: string[]): void;
setEnableEmbedOrder(v: boolean): void;
@@ -68,6 +72,8 @@ export const usePreferencesStore = create(
forceCompactEpisodeView: false,
sourceOrder: [],
enableSourceOrder: false,
+ lastSuccessfulSource: null,
+ enableLastSuccessfulSource: true,
disabledSources: [],
embedOrder: [],
enableEmbedOrder: false,
@@ -136,6 +142,16 @@ export const usePreferencesStore = create(
s.enableSourceOrder = v;
});
},
+ setLastSuccessfulSource(v) {
+ set((s) => {
+ s.lastSuccessfulSource = v;
+ });
+ },
+ setEnableLastSuccessfulSource(v) {
+ set((s) => {
+ s.enableLastSuccessfulSource = v;
+ });
+ },
setDisabledSources(v) {
set((s) => {
s.disabledSources = v;
From 0c192d2582ad49ceb66f2d229350f1d58e3bf6b9 Mon Sep 17 00:00:00 2001
From: Pas <74743263+Pasithea0@users.noreply.github.com>
Date: Mon, 3 Nov 2025 11:15:42 -0700
Subject: [PATCH 03/70] bug fix the last successful source feature
---
src/assets/locales/en.json | 2 +-
.../player/hooks/useSourceSelection.ts | 45 ++++++++++++++++++-
src/stores/player/slices/display.ts | 3 ++
3 files changed, 47 insertions(+), 3 deletions(-)
diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json
index de33a8e3..a5de66c9 100644
--- a/src/assets/locales/en.json
+++ b/src/assets/locales/en.json
@@ -1110,7 +1110,7 @@
"manualSourceLabel": "Manual source selection",
"lastSuccessfulSource": "Last successful source",
"lastSuccessfulSourceDescription": "Automatically prioritize the source that successfully provided content for the previous episode. This helps ensure continuity when watching series.",
- "lastSuccessfulSourceEnableLabel": "Enable last successful source"
+ "lastSuccessfulSourceEnableLabel": "Last successful source"
},
"reset": "Reset",
"save": "Save",
diff --git a/src/components/player/hooks/useSourceSelection.ts b/src/components/player/hooks/useSourceSelection.ts
index 428096f6..6808b8ce 100644
--- a/src/components/player/hooks/useSourceSelection.ts
+++ b/src/components/player/hooks/useSourceSelection.ts
@@ -22,6 +22,7 @@ import { convertRunoutputToSource } from "@/components/player/utils/convertRunou
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { metaToScrapeMedia } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
+import { usePreferencesStore } from "@/stores/preferences";
export function useEmbedScraping(
routerId: string,
@@ -37,6 +38,12 @@ export function useEmbedScraping(
const meta = usePlayerStore((s) => s.meta);
const router = useOverlayRouter(routerId);
const { report } = useReportProviders();
+ const setLastSuccessfulSource = usePreferencesStore(
+ (s) => s.setLastSuccessfulSource,
+ );
+ const enableLastSuccessfulSource = usePreferencesStore(
+ (s) => s.enableLastSuccessfulSource,
+ );
const [request, run] = useAsyncFn(async () => {
const providerApiUrl = getLoadbalancedProviderApiUrl();
@@ -83,8 +90,21 @@ export function useEmbedScraping(
convertProviderCaption(result.stream[0].captions),
progress,
);
+ // Save the last successful source when manually selected
+ if (enableLastSuccessfulSource) {
+ setLastSuccessfulSource(sourceId);
+ }
router.close();
- }, [embedId, sourceId, meta, router, report, setCaption]);
+ }, [
+ embedId,
+ sourceId,
+ meta,
+ router,
+ report,
+ setCaption,
+ enableLastSuccessfulSource,
+ setLastSuccessfulSource,
+ ]);
return {
run,
@@ -102,6 +122,12 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
const progress = usePlayerStore((s) => s.progress.time);
const router = useOverlayRouter(routerId);
const { report } = useReportProviders();
+ const setLastSuccessfulSource = usePreferencesStore(
+ (s) => s.setLastSuccessfulSource,
+ );
+ const enableLastSuccessfulSource = usePreferencesStore(
+ (s) => s.enableLastSuccessfulSource,
+ );
const [request, run] = useAsyncFn(async () => {
if (!sourceId || !meta) return null;
@@ -147,6 +173,10 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
progress,
);
setSourceId(sourceId);
+ // Save the last successful source when manually selected
+ if (enableLastSuccessfulSource) {
+ setLastSuccessfulSource(sourceId);
+ }
router.close();
return null;
}
@@ -203,10 +233,21 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
convertProviderCaption(embedResult.stream[0].captions),
progress,
);
+ // Save the last successful source when manually selected
+ if (enableLastSuccessfulSource) {
+ setLastSuccessfulSource(sourceId);
+ }
router.close();
}
return result.embeds;
- }, [sourceId, meta, router, setCaption]);
+ }, [
+ sourceId,
+ meta,
+ router,
+ setCaption,
+ enableLastSuccessfulSource,
+ setLastSuccessfulSource,
+ ]);
return {
run,
diff --git a/src/stores/player/slices/display.ts b/src/stores/player/slices/display.ts
index 21005cc2..f6070e52 100644
--- a/src/stores/player/slices/display.ts
+++ b/src/stores/player/slices/display.ts
@@ -1,6 +1,7 @@
import { DisplayInterface } from "@/components/player/display/displayInterface";
import { playerStatus } from "@/stores/player/slices/source";
import { MakeSlice } from "@/stores/player/slices/types";
+import { usePreferencesStore } from "@/stores/preferences";
export interface DisplaySlice {
display: DisplayInterface | null;
@@ -105,6 +106,8 @@ export const createDisplaySlice: MakeSlice = (set, get) => ({
s.status = playerStatus.PLAYBACK_ERROR;
s.interface.error = err;
});
+ // Reset last successful source on playback error
+ usePreferencesStore.getState().setLastSuccessfulSource(null);
});
set((s) => {
From 65246b8be97e1d5c4cf91a4a137d6f2d82e8d815 Mon Sep 17 00:00:00 2001
From: Pas <74743263+Pasithea0@users.noreply.github.com>
Date: Mon, 3 Nov 2025 14:22:53 -0700
Subject: [PATCH 04/70] fix the source getting reset when we change sources
Changing sources causes a non fatal error, but we were clearing the last successful source when any error happened. Instead we can clear if it's fatal.
---
src/pages/parts/player/PlaybackErrorPart.tsx | 8 +++++++-
src/stores/player/slices/display.ts | 3 ---
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/src/pages/parts/player/PlaybackErrorPart.tsx b/src/pages/parts/player/PlaybackErrorPart.tsx
index 5081e829..975d1461 100644
--- a/src/pages/parts/player/PlaybackErrorPart.tsx
+++ b/src/pages/parts/player/PlaybackErrorPart.tsx
@@ -10,6 +10,7 @@ import { Title } from "@/components/text/Title";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout";
import { usePlayerStore } from "@/stores/player/store";
+import { usePreferencesStore } from "@/stores/preferences";
import { ErrorCardInModal } from "../errors/ErrorCard";
@@ -19,15 +20,20 @@ export function PlaybackErrorPart() {
const modal = useModal("error");
const settingsRouter = useOverlayRouter("settings");
const hasOpenedSettings = useRef(false);
+ const setLastSuccessfulSource = usePreferencesStore(
+ (s) => s.setLastSuccessfulSource,
+ );
// Automatically open the settings overlay when a playback error occurs
useEffect(() => {
if (playbackError && !hasOpenedSettings.current) {
hasOpenedSettings.current = true;
+ // Reset the last successful source when a playback error occurs
+ setLastSuccessfulSource(null);
settingsRouter.open();
settingsRouter.navigate("/source");
}
- }, [playbackError, settingsRouter]);
+ }, [playbackError, settingsRouter, setLastSuccessfulSource]);
const handleOpenSourcePicker = () => {
settingsRouter.open();
diff --git a/src/stores/player/slices/display.ts b/src/stores/player/slices/display.ts
index f6070e52..21005cc2 100644
--- a/src/stores/player/slices/display.ts
+++ b/src/stores/player/slices/display.ts
@@ -1,7 +1,6 @@
import { DisplayInterface } from "@/components/player/display/displayInterface";
import { playerStatus } from "@/stores/player/slices/source";
import { MakeSlice } from "@/stores/player/slices/types";
-import { usePreferencesStore } from "@/stores/preferences";
export interface DisplaySlice {
display: DisplayInterface | null;
@@ -106,8 +105,6 @@ export const createDisplaySlice: MakeSlice = (set, get) => ({
s.status = playerStatus.PLAYBACK_ERROR;
s.interface.error = err;
});
- // Reset last successful source on playback error
- usePreferencesStore.getState().setLastSuccessfulSource(null);
});
set((s) => {
From a7889d568b900b5628c1d530caf02ad243637ee7 Mon Sep 17 00:00:00 2001
From: Pas <74743263+Pasithea0@users.noreply.github.com>
Date: Tue, 4 Nov 2025 11:05:55 -0700
Subject: [PATCH 05/70] load all movie lists from trakt one after another
---
src/backend/metadata/traktApi.ts | 39 ++++++++++++++++------------
src/pages/discover/AllMovieLists.tsx | 4 +--
2 files changed, 24 insertions(+), 19 deletions(-)
diff --git a/src/backend/metadata/traktApi.ts b/src/backend/metadata/traktApi.ts
index 94dc67ca..9c8b0142 100644
--- a/src/backend/metadata/traktApi.ts
+++ b/src/backend/metadata/traktApi.ts
@@ -227,27 +227,32 @@ export const getMovieDetailsForIds = async (
// Process in smaller batches to avoid overwhelming the API
const batchSize = 10;
+ const batchPromises: Promise[] = [];
+
for (let i = 0; i < limitedIds.length; i += batchSize) {
const batch = limitedIds.slice(i, i + batchSize);
- const batchPromises = batch.map(async (id) => {
- try {
- const details = await getMediaDetails(
- id.toString(),
- TMDBContentTypes.MOVIE,
- );
- return details as TMDBMovieData;
- } catch (error) {
- console.error(`Failed to fetch movie details for ID ${id}:`, error);
- return null;
- }
- });
-
- const batchResults = await Promise.all(batchPromises);
- const validResults = batchResults.filter(
- (result): result is TMDBMovieData => result !== null,
+ const batchPromise = Promise.all(
+ batch.map(async (id) => {
+ try {
+ const details = await getMediaDetails(
+ id.toString(),
+ TMDBContentTypes.MOVIE,
+ );
+ return details as TMDBMovieData;
+ } catch (error) {
+ console.error(`Failed to fetch movie details for ID ${id}:`, error);
+ return null;
+ }
+ }),
+ ).then((batchResults) =>
+ batchResults.filter((result): result is TMDBMovieData => result !== null),
);
- movieDetails.push(...validResults);
+ batchPromises.push(batchPromise);
}
+ // Process all batches in parallel
+ const batchResults = await Promise.all(batchPromises);
+ movieDetails.push(...batchResults.flat());
+
return movieDetails;
};
diff --git a/src/pages/discover/AllMovieLists.tsx b/src/pages/discover/AllMovieLists.tsx
index 66014ae9..8ba5cfa2 100644
--- a/src/pages/discover/AllMovieLists.tsx
+++ b/src/pages/discover/AllMovieLists.tsx
@@ -38,13 +38,14 @@ export function DiscoverMore() {
const lists = await getCuratedMovieLists();
setCuratedLists(lists);
- // Fetch movie details for each list
+ // Fetch movie details for each list one after another
const details: { [listSlug: string]: TMDBMovieData[] } = {};
for (const list of lists) {
try {
const movies = await getMovieDetailsForIds(list.tmdbIds, 50);
if (movies.length > 0) {
details[list.listSlug] = movies;
+ setMovieDetails({ ...details });
}
} catch (error) {
console.error(
@@ -53,7 +54,6 @@ export function DiscoverMore() {
);
}
}
- setMovieDetails(details);
} catch (error) {
console.error("Failed to fetch curated lists:", error);
}
From 324cff18cc36361bda5c2db7f6e3eb04b19bf7d8 Mon Sep 17 00:00:00 2001
From: Pas <74743263+Pasithea0@users.noreply.github.com>
Date: Wed, 5 Nov 2025 10:25:48 -0700
Subject: [PATCH 06/70] fix some grey overlays on the video player with some
browsers
---
src/components/player/atoms/ProgressBar.tsx | 5 ++++-
src/components/player/base/SubtitleView.tsx | 1 +
src/components/player/internals/VideoContainer.tsx | 1 +
3 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/components/player/atoms/ProgressBar.tsx b/src/components/player/atoms/ProgressBar.tsx
index 90b91dcc..17af477a 100644
--- a/src/components/player/atoms/ProgressBar.tsx
+++ b/src/components/player/atoms/ProgressBar.tsx
@@ -62,7 +62,10 @@ function ThumbnailDisplay(props: { at: number; show: boolean }) {
className="h-24 border rounded-xl border-gray-800"
/>
)}
-
+
{formattedTime}
diff --git a/src/components/player/base/SubtitleView.tsx b/src/components/player/base/SubtitleView.tsx
index 83a6946b..3339d315 100644
--- a/src/components/player/base/SubtitleView.tsx
+++ b/src/components/player/base/SubtitleView.tsx
@@ -98,6 +98,7 @@ export function CaptionCue({
styling.backgroundBlur !== 0
? `blur(${Math.floor(styling.backgroundBlur * 64)}px)`
: "none",
+ isolation: styling.backgroundBlur !== 0 ? "isolate" : "auto",
fontWeight: styling.bold ? "bold" : "normal",
...textEffectStyles,
}}
diff --git a/src/components/player/internals/VideoContainer.tsx b/src/components/player/internals/VideoContainer.tsx
index 89d34433..ff935d51 100644
--- a/src/components/player/internals/VideoContainer.tsx
+++ b/src/components/player/internals/VideoContainer.tsx
@@ -103,6 +103,7 @@ function VideoElement() {