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() {