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