feat: add Mark as Watched/Unwatched button in addon details modal

This commit is contained in:
Ayush Gade 2025-10-14 10:57:39 +05:30
parent 4a1b0d3287
commit 4e93bb9614
8 changed files with 333 additions and 13 deletions

View file

@ -0,0 +1,27 @@
const EventEmitter = require('eventemitter3');
const events = new EventEmitter();
const overrides = new Map();
module.exports = {
set: (metaId, watched) => {
if (typeof metaId !== 'string') return;
if (watched === null || typeof watched === 'undefined') {
overrides.delete(metaId);
} else {
overrides.set(metaId, !!watched);
}
events.emit('change', metaId, watched);
},
get: (metaId) => {
return overrides.has(metaId) ? overrides.get(metaId) : null;
},
clear: (metaId) => {
overrides.delete(metaId);
events.emit('change', metaId, null);
},
onChange: (cb) => {
events.on('change', cb);
return () => events.off('change', cb);
}
};

View file

@ -9,8 +9,12 @@ const styles = require('./styles');
const { Tooltip } = require('stremio/common/Tooltips');
const ActionButton = ({ className, icon, label, tooltip, ...props }) => {
const labelText = typeof label === 'string' ? label : '';
const hasLabel = !tooltip && label != null;
const wide = !tooltip && (typeof label === 'string' || React.isValidElement(label));
return (
<Button title={tooltip ? '' : label} {...props} className={classnames(className, styles['action-button-container'], { 'wide': typeof label === 'string' && !tooltip })}>
<Button title={tooltip ? '' : labelText} {...props} className={classnames(className, styles['action-button-container'], { 'wide': wide })}>
{
tooltip === true ?
<Tooltip label={label} position={'top'} />
@ -26,8 +30,9 @@ const ActionButton = ({ className, icon, label, tooltip, ...props }) => {
null
}
{
!tooltip && typeof label === 'string' && label.length > 0 ?
hasLabel ?
<div className={styles['label-container']}>
{/* render label (string or node) directly */}
<div className={styles['label']}>{label}</div>
</div>
:
@ -40,7 +45,7 @@ const ActionButton = ({ className, icon, label, tooltip, ...props }) => {
ActionButton.propTypes = {
className: PropTypes.string,
icon: PropTypes.string,
label: PropTypes.string,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
tooltip: PropTypes.bool
};

View file

@ -51,6 +51,11 @@
}
}
.action-button-container.wide {
border-radius: 2rem; // pill shape when wide
padding: 0 0.8rem;
}
@media @phone-landscape {
.action-button-container {
.label-container {

View file

@ -25,7 +25,7 @@ const ALLOWED_LINK_REDIRECTS = [
routesRegexp.metadetails.regexp
];
const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary, ratingInfo }, ref) => {
const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary, watched, markAsWatched, markAsUnwatched, ratingInfo }, ref) => {
const { t } = useTranslation();
const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false);
const linksGroups = React.useMemo(() => {
@ -208,6 +208,29 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
:
null
}
{
typeof (watched ? markAsUnwatched : markAsWatched) === 'function' ?
<ActionButton
className={styles['action-button']}
icon={watched ? '' : 'mark-watched'}
label={
<>
{watched ?
<Icon className={styles['inline-icon']} name={'close'} /> :
<Icon className={styles['inline-icon']} name={'checkmark'} />
}
<span style={{ marginLeft: '6px' }}>
{watched ? 'Mark as Unwatched' : 'Mark as Watched'}
</span>
</>
}
tooltip={false}
tabIndex={compact ? -1 : 0}
onClick={watched ? markAsUnwatched : markAsWatched}
/>
:
null
}
{
typeof trailerHref === 'string' ?
<ActionButton
@ -298,6 +321,9 @@ MetaPreview.propTypes = {
trailerStreams: PropTypes.array,
inLibrary: PropTypes.bool,
toggleInLibrary: PropTypes.func,
watched: PropTypes.bool,
markAsWatched: PropTypes.func,
markAsUnwatched: PropTypes.func,
ratingInfo: PropTypes.object,
};

View file

@ -1,13 +1,13 @@
// Copyright (C) 2017-2025 Smart code 203358507
import { useMemo, useCallback } from 'react';
import { useMemo, useCallback, useState, useEffect } from 'react';
import { useServices } from 'stremio/services';
const useRating = (ratingInfo?: Loadable<RatingInfo>) => {
const { core } = useServices();
const setRating = useCallback((status: Rating) => {
core.transport.dispatch({
const setRating = useCallback(async (status: Rating) => {
await core.transport.dispatch({
action: 'MetaDetails',
args: {
action: 'Rate',
@ -16,10 +16,13 @@ const useRating = (ratingInfo?: Loadable<RatingInfo>) => {
});
}, []);
// optimistic local status so UI updates immediately
const [localStatus, setLocalStatus] = useState<Rating | null>(null);
const status = useMemo(() => {
const content = ratingInfo?.type === 'Ready' ? ratingInfo.content as RatingInfo : null;
return content?.status;
}, [ratingInfo]);
return localStatus !== null ? localStatus : (content?.status ?? null);
}, [ratingInfo, localStatus]);
const liked = useMemo(() => {
return status === 'liked';
@ -30,13 +33,25 @@ const useRating = (ratingInfo?: Loadable<RatingInfo>) => {
}, [status]);
const onLiked = useCallback(() => {
setRating(status === 'liked' ? null : 'liked');
const next = status === 'liked' ? null : 'liked';
setLocalStatus(next);
setRating(next);
}, [status]);
const onLoved = useCallback(() => {
setRating(status === 'loved' ? null : 'loved');
const next = status === 'loved' ? null : 'loved';
setLocalStatus(next);
setRating(next);
}, [status]);
// clear local override when server state changes
useEffect(() => {
const content = ratingInfo?.type === 'Ready' ? ratingInfo.content as RatingInfo : null;
if (localStatus !== null && content?.status !== localStatus) {
setLocalStatus(null);
}
}, [ratingInfo, localStatus]);
return {
onLiked,
onLoved,

View file

@ -76,6 +76,8 @@
margin-bottom: 2rem;
}
.logo {
height: 9rem;
object-fit: contain;
@ -199,6 +201,16 @@
&:not(:last-child) {
margin-right: 1rem;
}
.inline-icon {
display: inline-block;
width: 1rem;
height: 1rem;
margin-right: 0.5rem;
vertical-align: middle;
color: var(--primary-foreground-color);
opacity: 0.9;
}
&.show-button {
&:hover, &:focus {

View file

@ -11,6 +11,8 @@ const useTranslate = require('stremio/common/useTranslate');
const MetaRowPlaceholder = require('./MetaRowPlaceholder');
const styles = require('./styles');
const watchedOverrides = require('stremio/common/watchedOverrides');
const MetaRow = ({ className, title, catalog, message, itemComponent, notifications }) => {
const t = useTranslate();
@ -53,12 +55,18 @@ const MetaRow = ({ className, title, catalog, message, itemComponent, notificati
{
ReactIs.isValidElementType(itemComponent) ?
items.slice(0, CONSTANTS.CATALOG_PREVIEW_SIZE).map((item, index) => {
return React.createElement(itemComponent, {
const override = watchedOverrides.get(item.id);
const props = {
...item,
key: index,
className: classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${item.posterShape}`]),
notifications,
});
};
if (override !== null) {
props.watched = override;
}
return React.createElement(itemComponent, props);
})
:
null

View file

@ -64,6 +64,225 @@ const MetaDetails = ({ urlParams, queryParams }) => {
}
});
}, [metaDetails]);
const markAsWatched = React.useCallback(async () => {
if (metaDetails.libraryItem && typeof metaDetails.libraryItem._id === 'string') {
await core.transport.dispatch({
action: 'Ctx',
args: {
action: 'LibraryItemMarkAsWatched',
args: {
id: metaDetails.libraryItem._id,
is_watched: true
}
}
});
// after core processes the change, reload the board model so board cards get updated
await core.transport.dispatch({
action: 'Load',
args: { model: 'CatalogsWithExtra', args: { extra: [] } }
}, 'board');
}
}, [metaDetails.libraryItem]);
const markAsUnwatched = React.useCallback(async () => {
if (metaDetails.libraryItem && typeof metaDetails.libraryItem._id === 'string') {
await core.transport.dispatch({
action: 'Ctx',
args: {
action: 'LibraryItemMarkAsWatched',
args: {
id: metaDetails.libraryItem._id,
is_watched: false
}
}
});
// after core processes the change, reload the board model so the card watched state updates
await core.transport.dispatch({
action: 'Load',
args: { model: 'CatalogsWithExtra', args: { extra: [] } }
}, 'board');
}
}, [metaDetails.libraryItem]);
// optimistic local watched state with persistence
const [localWatched, setLocalWatched] = React.useState(() => {
// Check localStorage for persisted state
if (metaDetails.metaItem?.content?.content?.id) {
const storedState = localStorage.getItem(`watched_${metaDetails.metaItem.content.content.id}`);
return storedState ? JSON.parse(storedState) : null;
}
return null;
});
// set optimistic state when user clicks
const watchedOverrides = require('stremio/common/watchedOverrides');
const markAsWatchedOptimistic = React.useCallback(async () => {
if (metaDetails.metaItem?.content?.content?.id) {
const newState = true;
setLocalWatched(newState);
// Persist to localStorage
localStorage.setItem(`watched_${metaDetails.metaItem.content.content.id}`, JSON.stringify(newState));
}
// if item not in library, add it first
if (!(metaDetails.libraryItem && typeof metaDetails.libraryItem._id === 'string')) {
if (metaDetails.metaItem && metaDetails.metaItem.content && metaDetails.metaItem.content.content) {
// set client-side override immediately so board shows tick
watchedOverrides.set(metaDetails.metaItem.content.content.id, true);
await core.transport.dispatch({
action: 'Ctx',
args: {
action: 'AddToLibrary',
args: metaDetails.metaItem.content.content
}
});
// after adding, request board reload
await core.transport.dispatch({
action: 'Load',
args: { model: 'CatalogsWithExtra', args: { extra: [] } }
}, 'board');
}
return;
}
// otherwise dispatch mark now and reload board
if (metaDetails.libraryItem && typeof metaDetails.libraryItem._id === 'string') {
// set client-side override immediately so board shows tick
if (metaDetails.metaItem && metaDetails.metaItem.content && metaDetails.metaItem.content.content) {
watchedOverrides.set(metaDetails.metaItem.content.content.id, true);
}
await core.transport.dispatch({
action: 'Ctx',
args: {
action: 'LibraryItemMarkAsWatched',
args: {
id: metaDetails.libraryItem._id,
is_watched: true
}
}
});
await core.transport.dispatch({
action: 'Load',
args: { model: 'CatalogsWithExtra', args: { extra: [] } }
}, 'board');
}
}, [metaDetails.libraryItem]);
const markAsUnwatchedOptimistic = React.useCallback(async () => {
if (metaDetails.metaItem?.content?.content?.id) {
const newState = false;
setLocalWatched(newState);
// Persist to localStorage
localStorage.setItem(`watched_${metaDetails.metaItem.content.content.id}`, JSON.stringify(newState));
}
if (metaDetails.metaItem && metaDetails.metaItem.content && metaDetails.metaItem.content.content) {
watchedOverrides.set(metaDetails.metaItem.content.content.id, false);
}
if (metaDetails.libraryItem && typeof metaDetails.libraryItem._id === 'string') {
await core.transport.dispatch({
action: 'Ctx',
args: {
action: 'LibraryItemMarkAsWatched',
args: {
id: metaDetails.libraryItem._id,
is_watched: false
}
}
});
await core.transport.dispatch({
action: 'Load',
args: { model: 'CatalogsWithExtra', args: { extra: [] } }
}, 'board');
}
}, [metaDetails.libraryItem, metaDetails.metaItem]);
// dispatch mark/unmark when library item appears or when localWatched changes
const pendingMarkRef = React.useRef(false);
React.useEffect(() => {
const lib = metaDetails.libraryItem;
if (!lib || typeof lib._id !== 'string') {
return;
}
// if user requested watched and library item exists but core hasn't set it
if (localWatched === true && !lib.state?.is_watched && !pendingMarkRef.current) {
pendingMarkRef.current = true;
(async () => {
await core.transport.dispatch({
action: 'Ctx',
args: {
action: 'LibraryItemMarkAsWatched',
args: {
id: lib._id,
is_watched: true
}
}
});
// refresh board after core processes
await core.transport.dispatch({
action: 'Load',
args: { model: 'CatalogsWithExtra', args: { extra: [] } }
}, 'board');
// clear pending after a tick
setTimeout(() => { pendingMarkRef.current = false; }, 500);
})();
}
if (localWatched === false && lib.state?.is_watched && !pendingMarkRef.current) {
pendingMarkRef.current = true;
(async () => {
await core.transport.dispatch({
action: 'Ctx',
args: {
action: 'LibraryItemMarkAsWatched',
args: {
id: lib._id,
is_watched: false
}
}
});
await core.transport.dispatch({
action: 'Load',
args: { model: 'CatalogsWithExtra', args: { extra: [] } }
}, 'board');
setTimeout(() => { pendingMarkRef.current = false; }, 500);
})();
}
}, [localWatched, metaDetails.libraryItem, core.transport]);
// keep local override persisted and prefer localStorage overrides over server state
React.useEffect(() => {
const id = metaDetails.metaItem && metaDetails.metaItem.content && metaDetails.metaItem.content.content
? metaDetails.metaItem.content.content.id
: null;
if (!id) {
// nothing to do if no meta id yet
return;
}
// if user has a persisted override for this meta id, restore it and keep it
const stored = localStorage.getItem(`watched_${id}`);
if (stored !== null) {
try {
const parsed = JSON.parse(stored);
setLocalWatched(parsed);
} catch (e) {
// malformed value -> remove it
localStorage.removeItem(`watched_${id}`);
setLocalWatched(null);
}
// don't clear watchedOverrides in this case — we intentionally persist the user's choice
return;
}
// no persisted override -> clear optimistic local state and any watchedOverrides
setLocalWatched(null);
watchedOverrides.clear(id);
}, [metaDetails.libraryItem?.state?.is_watched, metaDetails.libraryItem?._id, metaDetails.metaItem]);
const toggleNotifications = React.useCallback(() => {
if (metaDetails.libraryItem) {
core.transport.dispatch({
@ -168,6 +387,9 @@ const MetaDetails = ({ urlParams, queryParams }) => {
trailerStreams={metaDetails.metaItem.content.content.trailerStreams}
inLibrary={metaDetails.metaItem.content.content.inLibrary}
toggleInLibrary={metaDetails.metaItem.content.content.inLibrary ? removeFromLibrary : addToLibrary}
watched={typeof localWatched === 'boolean' ? localWatched : metaDetails.libraryItem?.state?.is_watched}
markAsWatched={markAsWatchedOptimistic}
markAsUnwatched={markAsUnwatchedOptimistic}
metaId={metaDetails.metaItem.content.content.id}
ratingInfo={metaDetails.ratingInfo}
/>