diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 13fb4169..79271675 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -1055,7 +1055,10 @@ "sourceOrderEnableLabel": "Custom source order", "embedOrder": "Reordering embeds", "embedOrderDescription": "Drag and drop to reorder embeds. This will determine the order in which embeds are checked for the media you are trying to watch.

(The default order is best for most users)", - "embedOrderEnableLabel": "Custom embed order" + "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" }, "reset": "Reset", "save": "Save", diff --git a/src/backend/accounts/settings.ts b/src/backend/accounts/settings.ts index 7851b32f..affd9028 100644 --- a/src/backend/accounts/settings.ts +++ b/src/backend/accounts/settings.ts @@ -27,6 +27,7 @@ export interface SettingsInput { enableLowPerformanceMode?: boolean; enableNativeSubtitles?: boolean; enableHoldToBoost?: boolean; + manualSourceSelection?: boolean; } export interface SettingsResponse { @@ -52,6 +53,7 @@ export interface SettingsResponse { enableLowPerformanceMode?: boolean; enableNativeSubtitles?: boolean; enableHoldToBoost?: boolean; + manualSourceSelection?: boolean; } export function updateSettings( diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts index aa2f0ddf..7f8ddef8 100644 --- a/src/hooks/useSettingsState.ts +++ b/src/hooks/useSettingsState.ts @@ -67,6 +67,7 @@ export function useSettingsState( enableLowPerformanceMode: boolean, enableHoldToBoost: boolean, homeSectionOrder: string[], + manualSourceSelection: boolean, ) { const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] = useDerived(proxyUrls); @@ -188,6 +189,12 @@ export function useSettingsState( resetHomeSectionOrder, homeSectionOrderChanged, ] = useDerived(homeSectionOrder); + const [ + manualSourceSelectionState, + setManualSourceSelectionState, + resetManualSourceSelection, + manualSourceSelectionChanged, + ] = useDerived(manualSourceSelection); function reset() { resetTheme(); @@ -215,6 +222,7 @@ export function useSettingsState( resetEnableLowPerformanceMode(); resetEnableHoldToBoost(); resetHomeSectionOrder(); + resetManualSourceSelection(); } const changed = @@ -241,7 +249,8 @@ export function useSettingsState( forceCompactEpisodeViewChanged || enableLowPerformanceModeChanged || enableHoldToBoostChanged || - homeSectionOrderChanged; + homeSectionOrderChanged || + manualSourceSelectionChanged; return { reset, @@ -366,5 +375,10 @@ export function useSettingsState( set: setHomeSectionOrderState, changed: homeSectionOrderChanged, }, + manualSourceSelection: { + state: manualSourceSelectionState, + set: setManualSourceSelectionState, + changed: manualSourceSelectionChanged, + }, }; } diff --git a/src/pages/PlayerView.tsx b/src/pages/PlayerView.tsx index 609adacd..b08d7154 100644 --- a/src/pages/PlayerView.tsx +++ b/src/pages/PlayerView.tsx @@ -22,8 +22,10 @@ import { PlayerPart } from "@/pages/parts/player/PlayerPart"; import { ResumePart } from "@/pages/parts/player/ResumePart"; import { ScrapeErrorPart } from "@/pages/parts/player/ScrapeErrorPart"; 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 { usePreferencesStore } from "@/stores/preferences"; import { useProgressStore } from "@/stores/progress"; import { needsOnboarding } from "@/utils/onboarding"; import { parseTimestamp } from "@/utils/timestamp"; @@ -51,6 +53,9 @@ export function RealPlayerView() { } = usePlayer(); const { setPlayerMeta, scrapeMedia } = usePlayerMeta(); const backUrl = useLastNonPlayerLink(); + const manualSourceSelection = usePreferencesStore( + (s) => s.manualSourceSelection, + ); const router = useOverlayRouter("settings"); const openedWatchPartyRef = useRef(false); const progressItems = useProgressStore((s) => s.items); @@ -175,17 +180,21 @@ export function RealPlayerView() { /> ) : null} {status === playerStatus.SCRAPING && scrapeMedia ? ( - { - setErrorData({ - sourceOrder, - sources, - }); - setScrapeNotFound(); - }} - onGetStream={playAfterScrape} - /> + manualSourceSelection ? ( + + ) : ( + { + setErrorData({ + sourceOrder, + sources, + }); + setScrapeNotFound(); + }} + onGetStream={playAfterScrape} + /> + ) ) : null} {status === playerStatus.SCRAPE_NOT_FOUND && errorData ? ( diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 61f14935..fc0b4d20 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -200,6 +200,13 @@ export function SettingsPage() { const homeSectionOrder = usePreferencesStore((s) => s.homeSectionOrder); const setHomeSectionOrder = usePreferencesStore((s) => s.setHomeSectionOrder); + const manualSourceSelection = usePreferencesStore( + (s) => s.manualSourceSelection, + ); + const setManualSourceSelection = usePreferencesStore( + (s) => s.setManualSourceSelection, + ); + const account = useAuthStore((s) => s.account); const updateProfile = useAuthStore((s) => s.setAccountProfile); const updateDeviceName = useAuthStore((s) => s.updateDeviceName); @@ -253,6 +260,7 @@ export function SettingsPage() { enableLowPerformanceMode, enableHoldToBoost, homeSectionOrder, + manualSourceSelection, ); const availableSources = useMemo(() => { @@ -311,7 +319,8 @@ export function SettingsPage() { state.enableCarouselView.changed || state.forceCompactEpisodeView.changed || state.enableLowPerformanceMode.changed || - state.enableHoldToBoost.changed + state.enableHoldToBoost.changed || + state.manualSourceSelection.changed ) { await updateSettings(backendUrl, account, { applicationLanguage: state.appLanguage.state, @@ -333,6 +342,7 @@ export function SettingsPage() { forceCompactEpisodeView: state.forceCompactEpisodeView.state, enableLowPerformanceMode: state.enableLowPerformanceMode.state, enableHoldToBoost: state.enableHoldToBoost.state, + manualSourceSelection: state.manualSourceSelection.state, }); } if (state.deviceName.changed) { @@ -374,6 +384,7 @@ export function SettingsPage() { setEnableLowPerformanceMode(state.enableLowPerformanceMode.state); setEnableHoldToBoost(state.enableHoldToBoost.state); setHomeSectionOrder(state.homeSectionOrder.state); + setManualSourceSelection(state.manualSourceSelection.state); if (state.profile.state) { updateProfile(state.profile.state); @@ -419,6 +430,7 @@ export function SettingsPage() { setEnableLowPerformanceMode, setEnableHoldToBoost, setHomeSectionOrder, + setManualSourceSelection, ]); return ( @@ -471,6 +483,8 @@ export function SettingsPage() { setEnableLowPerformanceMode={state.enableLowPerformanceMode.set} enableHoldToBoost={state.enableHoldToBoost.state} setEnableHoldToBoost={state.enableHoldToBoost.set} + manualSourceSelection={state.manualSourceSelection.state} + setManualSourceSelection={state.manualSourceSelection.set} />
diff --git a/src/pages/parts/player/SourceSelectPart.tsx b/src/pages/parts/player/SourceSelectPart.tsx new file mode 100644 index 00000000..3ce05a65 --- /dev/null +++ b/src/pages/parts/player/SourceSelectPart.tsx @@ -0,0 +1,173 @@ +import { ScrapeMedia } from "@p-stream/providers"; +import React, { ReactNode, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; + +import { getCachedMetadata } from "@/backend/helpers/providerApi"; +import { Loading } from "@/components/layout/Loading"; +import { + useEmbedScraping, + useSourceScraping, +} from "@/components/player/hooks/useSourceSelection"; +import { Menu } from "@/components/player/internals/ContextMenu"; +import { SelectableLink } from "@/components/player/internals/ContextMenu/Links"; + +// Embed option component +function EmbedOption(props: { + embedId: string; + url: string; + sourceId: string; + routerId: string; +}) { + const { t } = useTranslation(); + const unknownEmbedName = t("player.menus.sources.unknownOption"); + + const embedName = useMemo(() => { + if (!props.embedId) return unknownEmbedName; + const sourceMeta = getCachedMetadata().find((s) => s.id === props.embedId); + return sourceMeta?.name ?? unknownEmbedName; + }, [props.embedId, unknownEmbedName]); + + const { run, errored, loading } = useEmbedScraping( + props.routerId, + props.sourceId, + props.url, + props.embedId, + ); + + return ( + + + {embedName} + + + ); +} + +// Embed selection view (when a source is selected) +function EmbedSelectionView(props: { + sourceId: string; + routerId: string; + onBack: () => void; +}) { + const { t } = useTranslation(); + const { run, notfound, loading, items, errored } = useSourceScraping( + props.sourceId, + props.routerId, + ); + + const sourceName = useMemo(() => { + if (!props.sourceId) return "..."; + const sourceMeta = getCachedMetadata().find((s) => s.id === props.sourceId); + return sourceMeta?.name ?? "..."; + }, [props.sourceId]); + + const lastSourceId = useRef(null); + useEffect(() => { + if (lastSourceId.current === props.sourceId) return; + lastSourceId.current = props.sourceId; + if (!props.sourceId) return; + run(); + }, [run, props.sourceId]); + + let content: ReactNode = null; + if (loading) + content = ( + + + + ); + else if (notfound) + content = ( + + {t("player.menus.sources.noStream.text")} + + ); + else if (items?.length === 0) + content = ( + + {t("player.menus.sources.noEmbeds.text")} + + ); + else if (errored) + content = ( + + {t("player.menus.sources.failed.text")} + + ); + else if (items && props.sourceId) + content = items.map((v) => ( + + )); + + return ( + <> + {sourceName} + {content} + + ); +} + +// Main source selection view +export function SourceSelectPart(props: { media: ScrapeMedia }) { + const { t } = useTranslation(); + const [selectedSourceId, setSelectedSourceId] = React.useState( + null, + ); + const routerId = "manualSourceSelect"; + + const sources = useMemo(() => { + const metaType = props.media.type; + if (!metaType) return []; + return getCachedMetadata() + .filter((v) => v.type === "source") + .filter((v) => v.mediaTypes?.includes(metaType)); + }, [props.media.type]); + + if (selectedSourceId) { + return ( +
+
+ + setSelectedSourceId(null)} + /> + +
+
+ ); + } + + return ( +
+
+ + {t("player.menus.sources.title")} + + {sources.map((v) => ( + setSelectedSourceId(v.id)} + > + {v.name} + + ))} + + +
+
+ ); +} diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index 7edc2896..00a873e4 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -31,6 +31,8 @@ export function PreferencesPart(props: { setEnableLowPerformanceMode: (v: boolean) => void; enableHoldToBoost: boolean; setEnableHoldToBoost: (v: boolean) => void; + manualSourceSelection: boolean; + setManualSourceSelection: (v: boolean) => void; }) { const { t } = useTranslation(); const sorted = sortLangCodes(appLanguageOptions.map((item) => item.code)); @@ -219,6 +221,26 @@ export function PreferencesPart(props: { {/* Column */}
+ {/* Manual Source Selection */} +
+

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

+

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

+
+ props.setManualSourceSelection(!props.manualSourceSelection) + } + 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.manualSourceLabel")} +

+
+

{t("settings.preferences.sourceOrder")}

diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index 065fcc8e..6354bfb3 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -23,6 +23,7 @@ export interface PreferencesStore { enableNativeSubtitles: boolean; enableHoldToBoost: boolean; homeSectionOrder: string[]; + manualSourceSelection: boolean; setEnableThumbnails(v: boolean): void; setEnableAutoplay(v: boolean): void; @@ -44,6 +45,7 @@ export interface PreferencesStore { setEnableNativeSubtitles(v: boolean): void; setEnableHoldToBoost(v: boolean): void; setHomeSectionOrder(v: string[]): void; + setManualSourceSelection(v: boolean): void; } export const usePreferencesStore = create( @@ -69,6 +71,7 @@ export const usePreferencesStore = create( enableNativeSubtitles: false, enableHoldToBoost: true, homeSectionOrder: ["watching", "bookmarks"], + manualSourceSelection: false, setEnableThumbnails(v) { set((s) => { s.enableThumbnails = v; @@ -169,6 +172,11 @@ export const usePreferencesStore = create( s.homeSectionOrder = v; }); }, + setManualSourceSelection(v) { + set((s) => { + s.manualSourceSelection = v; + }); + }, })), { name: "__MW::preferences",