feat(addons): reorder installed addon

This commit is contained in:
Aztup 2026-05-13 23:54:20 +02:00
parent 97b80891fe
commit 23b0f025f1
3 changed files with 199 additions and 26 deletions

View file

@ -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(() => (
<Icon className={styles['icon']} name={'addons'} />
), []);
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 (
<Button className={classnames(className, styles['addon-container'])} onKeyDown={onKeyDown} onClick={onOpenClick}>
{
reorderable ?
<div className={styles['reorder-container']} onClick={(e) => e.stopPropagation()}>
<Button
className={classnames(styles['reorder-button'], { [styles['disabled']]: !canMoveUp })}
title={t('ADDON_MOVE_UP')}
tabIndex={-1}
disabled={!canMoveUp}
onClick={moveUpClick}>
<Icon className={styles['reorder-icon']} name={'chevron-up'} />
</Button>
<Button
className={classnames(styles['reorder-button'], { [styles['disabled']]: !canMoveDown })}
title={t('ADDON_MOVE_DOWN')}
tabIndex={-1}
disabled={!canMoveDown}
onClick={moveDownClick}>
<Icon className={styles['reorder-icon']} name={'chevron-down'} />
</Button>
</div>
:
null
}
<div className={styles['logo-container']}>
<Image
className={styles['logo']}
@ -162,7 +193,12 @@ Addon.propTypes = {
onConfigure: PropTypes.func,
onOpen: PropTypes.func,
onShare: PropTypes.func,
dataset: PropTypes.object
dataset: PropTypes.object,
reorderable: PropTypes.bool,
canMoveUp: PropTypes.bool,
canMoveDown: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
};
module.exports = Addon;
module.exports = Addon;

View file

@ -18,6 +18,43 @@
border-color: var(--overlay-color);
}
.reorder-container {
flex: none;
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.3rem;
margin-right: 0.5rem;
.reorder-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.4rem;
height: 2.4rem;
border-radius: 0.4rem;
opacity: 0.6;
&:hover {
opacity: 1;
background-color: var(--overlay-color);
}
&.disabled {
opacity: 0.2;
pointer-events: none;
}
.reorder-icon {
display: block;
width: 1.4rem;
height: 1.4rem;
color: var(--primary-foreground-color);
}
}
}
.logo-container {
flex: none;
width: 8rem;

View file

@ -6,7 +6,7 @@ const classnames = require('classnames');
const { useTranslation } = require('react-i18next');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { useCore } = require('stremio/core');
const { usePlatform, useBinaryState, withCoreSuspender } = require('stremio/common');
const { usePlatform, useBinaryState, useProfile, withCoreSuspender } = require('stremio/common');
const { AddonDetailsModal, Button, Image, MainNavBars, ModalDialog, SearchBar, SharePrompt, TextInput, MultiselectMenu } = require('stremio/components');
const useToast = require('stremio/common/Toast/useToast');
const Addon = require('./Addon');
@ -17,11 +17,14 @@ const useSelectableInputs = require('./useSelectableInputs');
const styles = require('./styles');
const { AddonPlaceholder } = require('./AddonPlaceholder');
const STREMIO_API_URL = 'https://api.strem.io';
const Addons = ({ urlParams, queryParams }) => {
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 (
<MainNavBars className={styles['addons-container']} route={'addons'}>
@ -154,28 +242,40 @@ const Addons = ({ urlParams, queryParams }) => {
:
<div className={styles['addons-list-container']}>
{
installedAddons.catalog
displayedCatalog
.filter(searchFilterPredicate)
.map((addon, index) => (
<Addon
key={index}
className={classnames(styles['addon'], 'animation-fade-in')}
id={addon.manifest.id}
name={addon.manifest.name}
version={addon.manifest.version}
logo={addon.manifest.logo}
description={addon.manifest.description}
types={addon.manifest.types}
behaviorHints={addon.manifest.behaviorHints}
installed={addon.installed}
onInstall={onAddonInstall}
onUninstall={onAddonUninstall}
onConfigure={onAddonConfigure}
onOpen={onAddonOpen}
onShare={onAddonShare}
dataset={{ addon }}
/>
))
.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 (
<Addon
key={addon.transportUrl || index}
className={classnames(styles['addon'], 'animation-fade-in')}
id={addon.manifest.id}
name={addon.manifest.name}
version={addon.manifest.version}
logo={addon.manifest.logo}
description={addon.manifest.description}
types={addon.manifest.types}
behaviorHints={addon.manifest.behaviorHints}
installed={addon.installed}
onInstall={onAddonInstall}
onUninstall={onAddonUninstall}
onConfigure={onAddonConfigure}
onOpen={onAddonOpen}
onShare={onAddonShare}
dataset={{ addon }}
reorderable={canReorder}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMoveUp={onAddonMoveUp}
onMoveDown={onAddonMoveDown}
/>
);
})
}
</div>
:
@ -318,4 +418,4 @@ const AddonsFallback = () => (
<MainNavBars className={styles['addons-container']} route={'addons'} />
);
module.exports = withCoreSuspender(Addons, AddonsFallback);
module.exports = withCoreSuspender(Addons, AddonsFallback);