diff --git a/package.json b/package.json index efede27c..f603b695 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ ] }, "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@formkit/auto-animate": "^0.8.2", "@headlessui/react": "^1.7.19", "@ladjs/country-language": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b42151e2..4400cac9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,15 @@ overrides: rollup: npm:@rollup/wasm-node dependencies: + '@dnd-kit/core': + specifier: ^6.1.0 + version: 6.1.0(react-dom@18.3.1)(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^8.0.0 + version: 8.0.0(@dnd-kit/core@6.1.0)(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) '@formkit/auto-animate': specifier: ^0.8.2 version: 0.8.2 @@ -1568,6 +1577,49 @@ packages: '@babel/helper-validator-identifier': 7.22.20 to-fast-properties: 2.0.0 + /@dnd-kit/accessibility@3.1.0(react@18.3.1): + resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.3.1 + tslib: 2.6.2 + dev: false + + /@dnd-kit/core@6.1.0(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@dnd-kit/accessibility': 3.1.0(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.6.2 + dev: false + + /@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.1.0)(react@18.3.1): + resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==} + peerDependencies: + '@dnd-kit/core': ^6.1.0 + react: '>=16.8.0' + dependencies: + '@dnd-kit/core': 6.1.0(react-dom@18.3.1)(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.6.2 + dev: false + + /@dnd-kit/utilities@3.2.2(react@18.3.1): + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.3.1 + tslib: 2.6.2 + dev: false + /@esbuild/aix-ppc64@0.20.2: resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} engines: {node: '>=12'} @@ -5445,7 +5497,7 @@ packages: dependencies: lilconfig: 3.1.1 postcss: 8.4.38 - yaml: 2.4.1 + yaml: 2.4.2 dev: true /postcss-nested@6.0.1(postcss@8.4.38): @@ -7334,8 +7386,8 @@ packages: /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - /yaml@2.4.1: - resolution: {integrity: sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==} + /yaml@2.4.2: + resolution: {integrity: sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==} engines: {node: '>= 14'} hasBin: true dev: true diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 949f6aa3..b858a5c1 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -586,6 +586,8 @@ "autoplay": "Autoplay", "autoplayDescription": "Automatically play the next episode in a series after reaching the end. Can be enabled by users with the browser extension, a custom proxy, or with the default setup if allowed by the host.", "autoplayLabel": "Autoplay", + "sourceOrder": "Reordering sources", + "sourceOrderDescription": "Drag and drop to reorder sources. This will determine the order in which sources are checked for the media you are trying to watch. If a source is greyed out, it means it is not available on your device.", "title": "Preferences" }, "reset": "Reset", diff --git a/src/backend/providers/providers.ts b/src/backend/providers/providers.ts index ac4a7dfa..fba9b4d5 100644 --- a/src/backend/providers/providers.ts +++ b/src/backend/providers/providers.ts @@ -25,3 +25,11 @@ export function getProviders() { target: targets.BROWSER, }); } + +export function getAllProviders() { + return makeProviders({ + fetcher: makeStandardFetcher(fetch), + target: targets.BROWSER_EXTENSION, + consistentIpForRequests: true, + }); +} diff --git a/src/components/form/SortableList.tsx b/src/components/form/SortableList.tsx new file mode 100644 index 00000000..43da96fa --- /dev/null +++ b/src/components/form/SortableList.tsx @@ -0,0 +1,97 @@ +import { + DndContext, + DragEndEvent, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import classNames from "classnames"; + +import { Icon, Icons } from "../Icon"; + +export interface Item { + id: string; + name: string; + disabled?: boolean; +} + +function SortableItem(props: { item: Item }) { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: props.item.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {props.item.name} + {props.item.disabled && } + +
+ ); +} + +export function SortableList(props: { + items: Item[]; + setItems: (items: Item[]) => void; +}) { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over) return; + if (active.id !== over.id) { + const currentItems = props.items; + const oldIndex = currentItems.findIndex((item) => item.id === active.id); + const newIndex = currentItems.findIndex((item) => item.id === over.id); + const newItems = arrayMove(currentItems, oldIndex, newIndex); + props.setItems(newItems); + } + }; + + return ( + + +
+ {props.items.map((item) => ( + + ))} +
+
+
+ ); +} diff --git a/src/hooks/useProviderScrape.tsx b/src/hooks/useProviderScrape.tsx index 932c8449..e83973e2 100644 --- a/src/hooks/useProviderScrape.tsx +++ b/src/hooks/useProviderScrape.tsx @@ -14,6 +14,7 @@ import { } from "@/backend/helpers/providerApi"; import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers"; import { getProviders } from "@/backend/providers/providers"; +import { usePreferencesStore } from "@/stores/preferences"; export interface ScrapingItems { id: string; @@ -156,6 +157,8 @@ export function useScrape() { startScrape, } = useBaseScrape(); + const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder); + const startScraping = useCallback( async (media: ScrapeMedia) => { const providerApiUrl = getLoadbalancedProviderApiUrl(); @@ -181,6 +184,7 @@ export function useScrape() { const providers = getProviders(); const output = await providers.runAll({ media, + sourceOrder: preferredSourceOrder, events: { init: initEvent, start: startEvent, @@ -199,6 +203,7 @@ export function useScrape() { discoverEmbedsEvent, getResult, startScrape, + preferredSourceOrder, ], ); diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts index eb8cd73f..667093fb 100644 --- a/src/hooks/useSettingsState.ts +++ b/src/hooks/useSettingsState.ts @@ -52,6 +52,7 @@ export function useSettingsState( | undefined, enableThumbnails: boolean, enableAutoplay: boolean, + sourceOrder: string[], ) { const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] = useDerived(proxyUrls); @@ -91,6 +92,12 @@ export function useSettingsState( resetEnableAutoplay, enableAutoplayChanged, ] = useDerived(enableAutoplay); + const [ + sourceOrderState, + setSourceOrderState, + resetSourceOrder, + sourceOrderChanged, + ] = useDerived(sourceOrder); function reset() { resetTheme(); @@ -103,6 +110,7 @@ export function useSettingsState( resetProfile(); resetEnableThumbnails(); resetEnableAutoplay(); + resetSourceOrder(); } const changed = @@ -114,7 +122,8 @@ export function useSettingsState( proxyUrlsChanged || profileChanged || enableThumbnailsChanged || - enableAutoplayChanged; + enableAutoplayChanged || + sourceOrderChanged; return { reset, @@ -164,5 +173,10 @@ export function useSettingsState( set: setEnableAutoplayState, changed: enableAutoplayChanged, }, + sourceOrder: { + state: sourceOrderState, + set: setSourceOrderState, + changed: sourceOrderChanged, + }, }; } diff --git a/src/index.tsx b/src/index.tsx index 5d9083ef..3363aa15 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,7 +10,7 @@ import { createRoot } from "react-dom/client"; import { HelmetProvider } from "react-helmet-async"; import { useTranslation } from "react-i18next"; import { BrowserRouter, HashRouter } from "react-router-dom"; -import { useAsync } from "react-use"; +import { useAsync, useAsyncFn } from "react-use"; import { Button } from "@/components/buttons/Button"; import { Icon, Icons } from "@/components/Icon"; @@ -31,6 +31,10 @@ import { SettingsSyncer } from "@/stores/subtitles/SettingsSyncer"; import { ThemeProvider } from "@/stores/theme"; import { TurnstileProvider } from "@/stores/turnstile"; +import { + extensionInfo, + isExtensionActiveCached, +} from "./backend/extension/messaging"; import { initializeChromecast } from "./setup/chromecast"; import { initializeOldStores } from "./stores/__old/migrations"; @@ -149,6 +153,23 @@ function TheRouter(props: { children: ReactNode }) { return {props.children}; } +// Checks if the extension is installed +function ExtensionStatus() { + const { t } = useTranslation(); + const [state] = useAsyncFn(async () => { + if (!isExtensionActiveCached) { + return extensionInfo(); + } + }); + + if (state.loading) { + return ; + } + if (state.error) { + return {t("screens.loadingUserError.reload")}; + } + return null; +} const container = document.getElementById("root"); const root = createRoot(container!); @@ -158,6 +179,7 @@ root.render( }> + diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 2a827888..c7aac318 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -11,6 +11,7 @@ import { import { getSessions, updateSession } from "@/backend/accounts/sessions"; import { updateSettings } from "@/backend/accounts/settings"; import { editUser } from "@/backend/accounts/user"; +import { getAllProviders } from "@/backend/providers/providers"; import { Button } from "@/components/buttons/Button"; import { WideContainer } from "@/components/layout/WideContainer"; import { UserIcons } from "@/components/UserIcon"; @@ -126,6 +127,9 @@ export function SettingsPage() { const enableAutoplay = usePreferencesStore((s) => s.enableAutoplay); const setEnableAutoplay = usePreferencesStore((s) => s.setEnableAutoplay); + const sourceOrder = usePreferencesStore((s) => s.sourceOrder); + const setSourceOrder = usePreferencesStore((s) => s.setSourceOrder); + const account = useAuthStore((s) => s.account); const updateProfile = useAuthStore((s) => s.setAccountProfile); const updateDeviceName = useAuthStore((s) => s.updateDeviceName); @@ -149,8 +153,25 @@ export function SettingsPage() { account?.profile, enableThumbnails, enableAutoplay, + sourceOrder, ); + const availableSources = useMemo(() => { + const sources = getAllProviders().listSources(); + const sourceIDs = sources.map((s) => s.id); + const stateSources = state.sourceOrder.state; + + // Filter out sources that are not in `stateSources` and are in `sources` + const updatedSources = stateSources.filter((ss) => sourceIDs.includes(ss)); + + // Add sources from `sources` that are not in `stateSources` + const missingSources = sources + .filter((s) => !stateSources.includes(s.id)) + .map((s) => s.id); + + return [...updatedSources, ...missingSources]; + }, [state.sourceOrder.state]); + useEffect(() => { setPreviewTheme(activeTheme ?? "default"); }, [setPreviewTheme, activeTheme]); @@ -202,6 +223,7 @@ export function SettingsPage() { setEnableThumbnails(state.enableThumbnails.state); setEnableAutoplay(state.enableAutoplay.state); + setSourceOrder(state.sourceOrder.state); setAppLanguage(state.appLanguage.state); setTheme(state.theme.state); setSubStyling(state.subtitleStyling.state); @@ -228,6 +250,7 @@ export function SettingsPage() { setEnableThumbnails, state, setEnableAutoplay, + setSourceOrder, setAppLanguage, setTheme, setSubStyling, @@ -278,6 +301,8 @@ export function SettingsPage() { setEnableThumbnails={state.enableThumbnails.set} enableAutoplay={state.enableAutoplay.state} setEnableAutoplay={state.enableAutoplay.set} + sourceOrder={availableSources} + setSourceOrder={state.sourceOrder.set} />
diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index 71f9c5f8..d127655f 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -1,9 +1,13 @@ import classNames from "classnames"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { getAllProviders, getProviders } from "@/backend/providers/providers"; +import { Button } from "@/components/buttons/Button"; import { Toggle } from "@/components/buttons/Toggle"; import { FlagIcon } from "@/components/FlagIcon"; import { Dropdown } from "@/components/form/Dropdown"; +import { SortableList } from "@/components/form/SortableList"; import { Heading1 } from "@/components/utils/Text"; import { appLanguageOptions } from "@/setup/i18n"; import { isAutoplayAllowed } from "@/utils/autoplay"; @@ -16,6 +20,8 @@ export function PreferencesPart(props: { setEnableThumbnails: (v: boolean) => void; enableAutoplay: boolean; setEnableAutoplay: (v: boolean) => void; + sourceOrder: string[]; + setSourceOrder: (v: string[]) => void; }) { const { t } = useTranslation(); const sorted = sortLangCodes(appLanguageOptions.map((item) => item.code)); @@ -34,6 +40,17 @@ export function PreferencesPart(props: { (item) => item.id === getLocaleInfo(props.language)?.code, ); + const allSources = getAllProviders().listSources(); + + const sourceItems = useMemo(() => { + const currentDeviceSources = getProviders().listSources(); + return props.sourceOrder.map((id) => ({ + id, + name: allSources.find((s) => s.id === id)?.name || id, + disabled: !currentDeviceSources.find((s) => s.id === id), + })); + }, [props.sourceOrder, allSources]); + return (
{t("settings.preferences.title")} @@ -94,6 +111,29 @@ export function PreferencesPart(props: {

+ +
+

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

+

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

+ + + props.setSourceOrder(items.map((item) => item.id)) + } + /> + +
); } diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index b2c55afd..6e54129d 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -4,26 +4,35 @@ import { immer } from "zustand/middleware/immer"; export interface PreferencesStore { enableThumbnails: boolean; - setEnableThumbnails(v: boolean): void; enableAutoplay: boolean; + sourceOrder: string[]; + + setEnableThumbnails(v: boolean): void; setEnableAutoplay(v: boolean): void; + setSourceOrder(v: string[]): void; } export const usePreferencesStore = create( persist( immer((set) => ({ enableThumbnails: false, + enableAutoplay: true, + sourceOrder: [], setEnableThumbnails(v) { set((s) => { s.enableThumbnails = v; }); }, - enableAutoplay: true, setEnableAutoplay(v) { set((s) => { s.enableAutoplay = v; }); }, + setSourceOrder(v) { + set((s) => { + s.sourceOrder = v; + }); + }, })), { name: "__MW::preferences",