mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-03-11 21:27:05 +00:00
feat: add Mark as Watched/Unwatched button in addon details modal
This commit is contained in:
parent
4a1b0d3287
commit
4e93bb9614
8 changed files with 333 additions and 13 deletions
27
src/common/watchedOverrides.js
Normal file
27
src/common/watchedOverrides.js
Normal 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue