Upcoming Episode Hover Indicators

This commit is contained in:
darkhan 2026-05-21 19:28:23 +05:00
parent a9ad4cefc8
commit 396f71ad4e
8 changed files with 165 additions and 75 deletions

View file

@ -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;

View file

@ -18,6 +18,7 @@ function wrapPromise(promise) {
(error) => {
status = 'error';
result = error;
console.error('[CoreSuspender] Rejected promise error:', error);
}
);
return {

View file

@ -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;
}, []);

View file

@ -132,6 +132,7 @@ const LibItem = ({ _id, removable, notifications, watched, ...props }) => {
return (
<MetaItem
{...props}
_id={_id}
watched={watched}
newVideos={newVideos}
options={options}

View file

@ -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>
);
});

View file

@ -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;
}

View file

@ -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

View file

@ -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;
};