mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-05-24 08:32:10 +00:00
Upcoming Episode Hover Indicators
This commit is contained in:
parent
a9ad4cefc8
commit
396f71ad4e
8 changed files with 165 additions and 75 deletions
|
|
@ -71,8 +71,12 @@ const ServicesToaster = () => {
|
|||
core.transport.on('CoreEvent', onCoreEvent);
|
||||
dragAndDrop.on('error', onDragAndDropError);
|
||||
return () => {
|
||||
core.transport.off('CoreEvent', onCoreEvent);
|
||||
dragAndDrop.off('error', onDragAndDropError);
|
||||
if (core && core.transport) {
|
||||
core.transport.off('CoreEvent', onCoreEvent);
|
||||
}
|
||||
if (dragAndDrop) {
|
||||
dragAndDrop.off('error', onDragAndDropError);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ function wrapPromise(promise) {
|
|||
(error) => {
|
||||
status = 'error';
|
||||
result = error;
|
||||
console.error('[CoreSuspender] Rejected promise error:', error);
|
||||
}
|
||||
);
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -12,12 +12,17 @@ const useModelState = ({ action, ...args }) => {
|
|||
const { core } = useServices();
|
||||
const routeFocused = useRouteFocused();
|
||||
const mountedRef = React.useRef(false);
|
||||
const [model, timeout, map, deps] = React.useMemo(() => {
|
||||
return [args.model, args.timeout, args.map, args.deps];
|
||||
}, []);
|
||||
const model = args.model;
|
||||
const timeout = args.timeout;
|
||||
const map = args.map;
|
||||
const deps = args.deps;
|
||||
const skipUnload = args.skipUnload;
|
||||
const { getState } = useCoreSuspender();
|
||||
const [state, setState] = React.useReducer(
|
||||
(prevState, nextState) => {
|
||||
if (!prevState || !nextState) {
|
||||
return nextState;
|
||||
}
|
||||
return Object.keys(prevState).reduce((result, key) => {
|
||||
result[key] = deepEqual(prevState[key], nextState[key]) ? prevState[key] : nextState[key];
|
||||
return result;
|
||||
|
|
@ -25,26 +30,39 @@ const useModelState = ({ action, ...args }) => {
|
|||
},
|
||||
undefined,
|
||||
() => {
|
||||
if (typeof map === 'function') {
|
||||
return map(getState(model));
|
||||
} else {
|
||||
return getState(model);
|
||||
if (!model) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (typeof map === 'function') {
|
||||
return map(getState(model));
|
||||
} else {
|
||||
return getState(model);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Promise) {
|
||||
throw error;
|
||||
}
|
||||
console.error(`[useModelState] Error in model ${model}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
React.useInsertionEffect(() => {
|
||||
if (action) {
|
||||
if (action && model) {
|
||||
core.transport.dispatch(action, model);
|
||||
}
|
||||
}, [action]);
|
||||
}, [action, model]);
|
||||
React.useInsertionEffect(() => {
|
||||
return () => {
|
||||
core.transport.dispatch({ action: 'Unload' }, model);
|
||||
if (model && !skipUnload) {
|
||||
core.transport.dispatch({ action: 'Unload' }, model);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [model, skipUnload]);
|
||||
React.useInsertionEffect(() => {
|
||||
const onNewState = async (models) => {
|
||||
if (models.indexOf(model) === -1 && (!Array.isArray(deps) || intersection(deps, models).length === 0)) {
|
||||
if (!model || (models.indexOf(model) === -1 && (!Array.isArray(deps) || intersection(deps, models).length === 0))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +76,7 @@ const useModelState = ({ action, ...args }) => {
|
|||
const onNewStateThrottled = throttle(onNewState, timeout);
|
||||
if (routeFocused) {
|
||||
core.transport.on('NewState', onNewStateThrottled);
|
||||
if (mountedRef.current) {
|
||||
if (mountedRef.current && model) {
|
||||
onNewState([model]);
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +84,7 @@ const useModelState = ({ action, ...args }) => {
|
|||
onNewStateThrottled.cancel();
|
||||
core.transport.off('NewState', onNewStateThrottled);
|
||||
};
|
||||
}, [routeFocused]);
|
||||
}, [routeFocused, model]);
|
||||
React.useInsertionEffect(() => {
|
||||
mountedRef.current = true;
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ const LibItem = ({ _id, removable, notifications, watched, ...props }) => {
|
|||
return (
|
||||
<MetaItem
|
||||
{...props}
|
||||
_id={_id}
|
||||
watched={watched}
|
||||
newVideos={newVideos}
|
||||
options={options}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const filterInvalidDOMProps = require('filter-invalid-dom-props').default;
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { default: Button } = require('stremio/components/Button');
|
||||
|
|
@ -12,10 +11,34 @@ const Multiselect = require('stremio/components/Multiselect');
|
|||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const { ICON_FOR_TYPE } = require('stremio/common/CONSTANTS');
|
||||
const styles = require('./styles');
|
||||
const useMetaDetailsForMetaItem = require('stremio/routes/Board/useMetaDetailsForMetaItem');
|
||||
const useTranslate = require('stremio/common/useTranslate');
|
||||
|
||||
const MetaItem = React.memo(({ className, type, name, poster, posterShape, posterChangeCursor, progress, newVideos, options, deepLinks, dataset, optionOnSelect, onDismissClick, onPlayClick, watched, nextEpisodeReleaseDate, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const t = useTranslate();
|
||||
const [menuOpen, onMenuOpen, onMenuClose] = useBinaryState(false);
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const [mousePos, setMousePos] = React.useState({ x: 0, y: 0 });
|
||||
const hoverTimeoutRef = React.useRef();
|
||||
|
||||
const onMouseEnter = React.useCallback(() => {
|
||||
hoverTimeoutRef.current = setTimeout(() => setIsHovered(true), 150);
|
||||
}, []);
|
||||
const onMouseLeave = React.useCallback(() => {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
const onMouseMove = React.useCallback((event) => {
|
||||
setMousePos({ x: event.clientX, y: event.clientY });
|
||||
}, []);
|
||||
|
||||
const upcomingDate = useMetaDetailsForMetaItem(
|
||||
{ id: props.id || props._id || dataset?.id || dataset?._id, type },
|
||||
isHovered && (type === 'series' || type === 'tv') && !nextEpisodeReleaseDate
|
||||
);
|
||||
|
||||
const displayDate = nextEpisodeReleaseDate || upcomingDate;
|
||||
|
||||
const href = React.useMemo(() => {
|
||||
return deepLinks ?
|
||||
typeof deepLinks.metaDetailsStreams === 'string' ?
|
||||
|
|
@ -62,17 +85,31 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, poste
|
|||
<Icon className={styles['icon']} name={'more-vertical'} />
|
||||
), []);
|
||||
const title = React.useMemo(() => {
|
||||
// Suppress native tooltip for series/tv immediately on hover
|
||||
// to prevent the browser's native box from appearing during the fetch delay.
|
||||
if (isHovered && (type === 'series' || type === 'tv')) {
|
||||
return null;
|
||||
}
|
||||
if (typeof nextEpisodeReleaseDate === 'string' && nextEpisodeReleaseDate.length > 0) {
|
||||
return `${name} (${nextEpisodeReleaseDate})`;
|
||||
}
|
||||
return name;
|
||||
}, [name, nextEpisodeReleaseDate]);
|
||||
}, [name, nextEpisodeReleaseDate, isHovered, type]);
|
||||
return (
|
||||
<Button title={title} href={href} {...filterInvalidDOMProps(props)} className={classnames(className, styles['meta-item-container'], styles['poster-shape-poster'], styles[`poster-shape-${posterShape}`], { 'active': menuOpen })} onClick={metaItemOnClick}>
|
||||
<Button
|
||||
title={title}
|
||||
href={href}
|
||||
{...filterInvalidDOMProps(props)}
|
||||
className={classnames(className, styles['meta-item-container'], styles['poster-shape-poster'], styles[`poster-shape-${posterShape}`], { 'active': menuOpen })}
|
||||
onClick={metaItemOnClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseMove={onMouseMove}
|
||||
>
|
||||
<div className={classnames(styles['poster-container'], { 'poster-change-cursor': posterChangeCursor })}>
|
||||
{
|
||||
onDismissClick ?
|
||||
<div title={t('LIBRARY_RESUME_DISMISS')} className={styles['dismiss-icon-layer']} onClick={onDismissClick}>
|
||||
<div title={t.string('LIBRARY_RESUME_DISMISS')} className={styles['dismiss-icon-layer']} onClick={onDismissClick}>
|
||||
<Icon className={styles['dismiss-icon']} name={'close'} />
|
||||
<div className={styles['dismiss-icon-backdrop']} />
|
||||
</div>
|
||||
|
|
@ -97,7 +134,7 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, poste
|
|||
</div>
|
||||
{
|
||||
onPlayClick ?
|
||||
<div title={t('CONTINUE_WATCHING')} className={styles['play-icon-layer']} onClick={onPlayClick}>
|
||||
<div title={t.string('CONTINUE_WATCHING')} className={styles['play-icon-layer']} onClick={onPlayClick}>
|
||||
<Icon className={styles['play-icon']} name={'play'} />
|
||||
<div className={styles['play-icon-outer']} />
|
||||
<div className={styles['play-icon-background']} />
|
||||
|
|
@ -155,6 +192,16 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, poste
|
|||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
isHovered && (type === 'series' || type === 'tv') ? (
|
||||
<div
|
||||
className={styles['upcoming-tooltip']}
|
||||
style={{ top: mousePos.y + 15, left: mousePos.x + 15 }}
|
||||
>
|
||||
{name}{displayDate ? ` (${t.string('UPCOMING')}: ${displayDate})` : ''}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -380,4 +380,18 @@
|
|||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upcoming-tooltip {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
color: var(--primary-foreground-color);
|
||||
border-radius: 0.4rem;
|
||||
pointer-events: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 0.2rem 1rem rgba(0, 0, 0, 0.5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
@ -4,7 +4,11 @@ const React = require('react');
|
|||
const classnames = require('classnames');
|
||||
const debounce = require('lodash.debounce');
|
||||
const useTranslate = require('stremio/common/useTranslate');
|
||||
const { useStreamingServer, useNotifications, withCoreSuspender, getVisibleChildrenRange, useProfile } = require('stremio/common');
|
||||
const useStreamingServer = require('stremio/common/useStreamingServer');
|
||||
const useNotifications = require('stremio/common/useNotifications');
|
||||
const { withCoreSuspender } = require('stremio/common/CoreSuspender');
|
||||
const getVisibleChildrenRange = require('stremio/common/getVisibleChildrenRange');
|
||||
const useProfile = require('stremio/common/useProfile');
|
||||
const { ContinueWatchingItem, EventModal, MainNavBars, MetaItem, MetaRow } = require('stremio/components');
|
||||
const useBoard = require('./useBoard');
|
||||
const useContinueWatchingPreview = require('./useContinueWatchingPreview');
|
||||
|
|
@ -16,12 +20,12 @@ const THRESHOLD = 5;
|
|||
|
||||
// Helper component to fetch and pass nextEpisodeReleaseDate for a single item
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const ContinueWatchingItemWithDetails = ({ item, notifications }) => {
|
||||
const nextEpisodeReleaseDate = useMetaDetailsForMetaItem(item);
|
||||
const ContinueWatchingItemWithDetails = (props) => {
|
||||
// Disable immediate fetch by passing enabled: false
|
||||
const nextEpisodeReleaseDate = useMetaDetailsForMetaItem(props, false);
|
||||
return (
|
||||
<ContinueWatchingItem
|
||||
{...item}
|
||||
notifications={notifications}
|
||||
{...props}
|
||||
nextEpisodeReleaseDate={nextEpisodeReleaseDate}
|
||||
/>
|
||||
);
|
||||
|
|
@ -71,19 +75,9 @@ const Board = () => {
|
|||
<MetaRow
|
||||
className={classnames(styles['board-row'], styles['continue-watching-row'], 'animation-fade-in')}
|
||||
title={t.string('BOARD_CONTINUE_WATCHING')}
|
||||
catalog={{
|
||||
...continueWatchingPreview,
|
||||
items: continueWatchingPreview.items.map((item, index) => (
|
||||
<ContinueWatchingItemWithDetails
|
||||
key={item.id || index} // Use item.id for key if available, otherwise index
|
||||
item={item}
|
||||
notifications={notifications}
|
||||
/>
|
||||
)),
|
||||
}}
|
||||
// We are passing a React element as itemComponent, so we don't need itemComponent prop here
|
||||
// The items array now contains the rendered components
|
||||
itemComponent={({ item }) => item} // This is a workaround to render the React elements directly
|
||||
catalog={continueWatchingPreview}
|
||||
notifications={notifications}
|
||||
itemComponent={ContinueWatchingItemWithDetails}
|
||||
/>
|
||||
:
|
||||
null
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
const React = require('react');
|
||||
const { useModelState, useProfile } = require('stremio/common');
|
||||
const useModelState = require('stremio/common/useModelState');
|
||||
const useProfile = require('stremio/common/useProfile');
|
||||
|
||||
const useMetaDetailsForMetaItem = (metaItemPreview) => {
|
||||
const useMetaDetailsForMetaItem = (metaItemPreview, enabled = true) => {
|
||||
const profile = useProfile();
|
||||
// Create a unique model name for each meta item to avoid state conflicts
|
||||
const modelName = React.useMemo(() => {
|
||||
return metaItemPreview && metaItemPreview.id ? `metaDetails_${metaItemPreview.id}` : null;
|
||||
}, [metaItemPreview?.id]);
|
||||
// Use the singleton model name 'meta_details' which is guaranteed to exist in the core
|
||||
const modelName = enabled ? 'meta_details' : null;
|
||||
|
||||
const metaDetails = useModelState({
|
||||
model: modelName,
|
||||
skipUnload: true,
|
||||
action: React.useMemo(() => {
|
||||
if (!metaItemPreview || !metaItemPreview.id || !metaItemPreview.type) {
|
||||
const id = metaItemPreview?.id || metaItemPreview?._id;
|
||||
if (!enabled || !id || !metaItemPreview?.type) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
|
|
@ -19,43 +20,53 @@ const useMetaDetailsForMetaItem = (metaItemPreview) => {
|
|||
args: {
|
||||
model: 'MetaDetails',
|
||||
args: {
|
||||
type: metaItemPreview.type,
|
||||
id: metaItemPreview.id
|
||||
metaPath: {
|
||||
resource: 'meta',
|
||||
type: metaItemPreview.type,
|
||||
id: id,
|
||||
extra: []
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [metaItemPreview?.id, metaItemPreview?.type])
|
||||
}, [enabled, metaItemPreview?.id, metaItemPreview?._id, metaItemPreview?.type])
|
||||
});
|
||||
|
||||
const nextEpisodeReleaseDate = React.useMemo(() => {
|
||||
if (metaDetails && metaDetails.content?.type === 'Ready' && Array.isArray(metaDetails.content.content.videos)) {
|
||||
// Ensure we are looking at the CORRECT item in the singleton model
|
||||
const id = metaItemPreview?.id || metaItemPreview?._id;
|
||||
const metaItem = metaDetails?.metaItem;
|
||||
if (enabled && metaItem?.content?.type === 'Ready' && metaItem.content.content?.id === id && Array.isArray(metaItem.content.content.videos)) {
|
||||
const metaItemContent = metaItem.content;
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0); // Normalize 'now' to start of day
|
||||
const nowTime = now.getTime();
|
||||
const next7Days = new Date(now);
|
||||
next7Days.setDate(now.getDate() + 7);
|
||||
const next7DaysTime = next7Days.getTime();
|
||||
|
||||
const upcomingVideos = metaDetails.content.content.videos.filter((video) => {
|
||||
// Check if the video is scheduled and has a release date
|
||||
if (video.scheduled && video.released) {
|
||||
// Parse the release date string (e.g., 'YYYY-MM-DD')
|
||||
const [year, month, day] = video.released.split('-').map(Number);
|
||||
const releaseDate = new Date(year, month - 1, day); // month - 1 because Date months are 0-indexed
|
||||
|
||||
// Only consider episodes that are in the future or today
|
||||
return releaseDate >= now;
|
||||
}
|
||||
return false;
|
||||
}).sort((a, b) => {
|
||||
// Sort by release date to find the soonest upcoming episode
|
||||
const dateA = new Date(a.released);
|
||||
const dateB = new Date(b.released);
|
||||
return dateA.getTime() - dateB.getTime();
|
||||
});
|
||||
const upcomingVideos = metaItemContent.content.videos
|
||||
.filter((video) => {
|
||||
if (video.released) {
|
||||
const releaseDate = new Date(video.released);
|
||||
const releaseTime = releaseDate.getTime();
|
||||
return !isNaN(releaseTime) && releaseTime >= nowTime && releaseTime <= next7DaysTime;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const timeA = new Date(a.released).getTime();
|
||||
const timeB = new Date(b.released).getTime();
|
||||
const validA = !isNaN(timeA);
|
||||
const validB = !isNaN(timeB);
|
||||
if (!validA && !validB) return 0;
|
||||
if (!validA) return 1;
|
||||
if (!validB) return -1;
|
||||
return timeA - timeB;
|
||||
});
|
||||
|
||||
if (upcomingVideos.length > 0) {
|
||||
const nextVideo = upcomingVideos[0];
|
||||
const [year, month, day] = nextVideo.released.split('-').map(Number);
|
||||
const releaseDate = new Date(year, month - 1, day);
|
||||
// Format the date string using the user's interface language
|
||||
return releaseDate.toLocaleDateString(profile.settings.interfaceLanguage, {
|
||||
const releaseDate = new Date(upcomingVideos[0].released);
|
||||
return releaseDate.toLocaleDateString(profile?.settings?.interfaceLanguage || 'en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
|
|
@ -63,7 +74,7 @@ const useMetaDetailsForMetaItem = (metaItemPreview) => {
|
|||
}
|
||||
}
|
||||
return null;
|
||||
}, [metaDetails, profile.settings.interfaceLanguage]);
|
||||
}, [enabled, metaDetails, profile?.settings?.interfaceLanguage, metaItemPreview?.id, metaItemPreview?._id]);
|
||||
|
||||
return nextEpisodeReleaseDate;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue