diff --git a/src/routes/Addons/Addon/Addon.js b/src/routes/Addons/Addon/Addon.js index 5a2f29631..ecdc4cd90 100644 --- a/src/routes/Addons/Addon/Addon.js +++ b/src/routes/Addons/Addon/Addon.js @@ -8,7 +8,7 @@ const { default: Icon } = require('@stremio/stremio-icons/react'); const { Button, Image } = require('stremio/components'); const styles = require('./styles'); -const Addon = ({ className, id, name, version, logo, description, types, behaviorHints, installed, onInstall, onUninstall, onConfigure, onOpen, onShare, dataset }) => { +const Addon = ({ className, id, name, version, logo, description, types, behaviorHints, installed, onInstall, onUninstall, onConfigure, onOpen, onShare, dataset, reorderable, canMoveUp, canMoveDown, onMoveUp, onMoveDown }) => { const { t } = useTranslation(); const onInstallClick = React.useCallback((event) => { event.stopPropagation(); @@ -73,8 +73,39 @@ const Addon = ({ className, id, name, version, logo, description, types, behavio const renderLogoFallback = React.useCallback(() => ( ), []); + const moveUpClick = React.useCallback((event) => { + event.stopPropagation(); + if (typeof onMoveUp === 'function') onMoveUp({ dataset }); + }, [onMoveUp, dataset]); + const moveDownClick = React.useCallback((event) => { + event.stopPropagation(); + if (typeof onMoveDown === 'function') onMoveDown({ dataset }); + }, [onMoveDown, dataset]); return ( + + + : + null + }
{ const { t } = useTranslation(); const platform = usePlatform(); const core = useCore(); const toast = useToast(); + const profile = useProfile(); const installedAddons = useInstalledAddons(urlParams); const remoteAddons = useRemoteAddons(urlParams); const [addonDetailsTransportUrl, setAddonDetailsTransportUrl] = useAddonDetailsTransportUrl(urlParams, queryParams); @@ -99,6 +102,89 @@ const Addons = ({ urlParams, queryParams }) => { const closeAddonDetails = React.useCallback(() => { setAddonDetailsTransportUrl(null); }, [setAddonDetailsTransportUrl]); + const [reorderedCatalog, setReorderedCatalog] = React.useState(null); + const pendingOrderRef = React.useRef(null); + const pushTimerRef = React.useRef(null); + const latestOrderRef = React.useRef(null); + React.useEffect(() => { + if (pendingOrderRef.current === null) { + setReorderedCatalog(null); + return; + } + const current = installedAddons.catalog.map((a) => a.transportUrl); + const pending = pendingOrderRef.current; + if (current.length === pending.length && current.every((u, i) => u === pending[i])) { + pendingOrderRef.current = null; + setReorderedCatalog(null); + } + }, [installedAddons.catalog]); + React.useEffect(() => () => { + if (pushTimerRef.current) clearTimeout(pushTimerRef.current); + }, []); + const isInstalledView = installedAddons.selected !== null; + const canReorder = isInstalledView + && profile?.auth?.key + && (!installedAddons.selected.request || installedAddons.selected.request.type === null) + && search.length === 0; + const displayedCatalog = (canReorder && reorderedCatalog) ? reorderedCatalog : installedAddons.catalog; + const doPush = React.useCallback(async (addons) => { + const authKey = profile?.auth?.key; + if (!authKey) { + pendingOrderRef.current = null; + setReorderedCatalog(null); + return; + } + const payload = addons.map((a) => ({ + manifest: a.manifest, + transportUrl: a.transportUrl, + flags: a.flags || { official: false, protected: false }, + })); + try { + const res = await fetch(`${STREMIO_API_URL}/api/addonCollectionSet`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'AddonCollectionSet', authKey, addons: payload }), + }).then((r) => r.json()); + if (res?.error) { + toast.show({ type: 'error', title: `Failed to save addon order: ${res.error.message || res.error}`, timeout: 8000 }); + pendingOrderRef.current = null; + setReorderedCatalog(null); + return; + } + } catch (err) { + toast.show({ type: 'error', title: `Failed to save addon order: ${err?.message || err}`, timeout: 8000 }); + pendingOrderRef.current = null; + setReorderedCatalog(null); + return; + } + core.transport.dispatch({ action: 'Ctx', args: { action: 'PullAddonsFromAPI' } }); + }, [profile, core, toast]); + const schedulePush = React.useCallback((addons) => { + latestOrderRef.current = addons; + pendingOrderRef.current = addons.map((a) => a.transportUrl); + if (pushTimerRef.current) clearTimeout(pushTimerRef.current); + pushTimerRef.current = setTimeout(() => { + pushTimerRef.current = null; + doPush(latestOrderRef.current); + }, 300); + }, [doPush]); + const swapAt = React.useCallback((url, direction) => { + const base = (reorderedCatalog || installedAddons.catalog).slice(); + const idx = base.findIndex((a) => a.transportUrl === url); + if (idx < 0) return; + const swapIdx = direction === 'up' ? idx - 1 : idx + 1; + if (swapIdx < 0 || swapIdx >= base.length) return; + if (base[idx].flags?.protected || base[swapIdx].flags?.protected) return; + [base[idx], base[swapIdx]] = [base[swapIdx], base[idx]]; + setReorderedCatalog(base); + schedulePush(base); + }, [reorderedCatalog, installedAddons.catalog, schedulePush]); + const onAddonMoveUp = React.useCallback(({ dataset }) => { + swapAt(dataset.addon.transportUrl, 'up'); + }, [swapAt]); + const onAddonMoveDown = React.useCallback(({ dataset }) => { + swapAt(dataset.addon.transportUrl, 'down'); + }, [swapAt]); const searchFilterPredicate = React.useCallback((addon) => { return search.length === 0 || ( @@ -113,6 +199,8 @@ const Addons = ({ urlParams, queryParams }) => { closeAddAddonModal(); setSearch(''); clearSharedAddon(); + pendingOrderRef.current = null; + setReorderedCatalog(null); }, [urlParams, queryParams]); return ( @@ -154,28 +242,40 @@ const Addons = ({ urlParams, queryParams }) => { :
{ - installedAddons.catalog + displayedCatalog .filter(searchFilterPredicate) - .map((addon, index) => ( - - )) + .map((addon, index, arr) => { + const isProtected = !!addon.flags?.protected; + const prev = arr[index - 1]; + const next = arr[index + 1]; + const canMoveUp = canReorder && !isProtected && prev && !prev.flags?.protected; + const canMoveDown = canReorder && !isProtected && !!next && !next.flags?.protected; + return ( + + ); + }) }
: @@ -318,4 +418,4 @@ const AddonsFallback = () => ( ); -module.exports = withCoreSuspender(Addons, AddonsFallback); +module.exports = withCoreSuspender(Addons, AddonsFallback); \ No newline at end of file