diff --git a/src/App/ServicesToaster.js b/src/App/ServicesToaster.js index 7ca7e2914..f0f8af011 100644 --- a/src/App/ServicesToaster.js +++ b/src/App/ServicesToaster.js @@ -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; diff --git a/src/common/CoreSuspender.js b/src/common/CoreSuspender.js index d225d5411..de9966f60 100644 --- a/src/common/CoreSuspender.js +++ b/src/common/CoreSuspender.js @@ -18,6 +18,7 @@ function wrapPromise(promise) { (error) => { status = 'error'; result = error; + console.error('[CoreSuspender] Rejected promise error:', error); } ); return { diff --git a/src/common/useModelState.js b/src/common/useModelState.js index da637b8ff..2e6bc4b6a 100644 --- a/src/common/useModelState.js +++ b/src/common/useModelState.js @@ -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; }, []); diff --git a/src/components/LibItem/LibItem.js b/src/components/LibItem/LibItem.js index 28769ddcf..26253d403 100644 --- a/src/components/LibItem/LibItem.js +++ b/src/components/LibItem/LibItem.js @@ -132,6 +132,7 @@ const LibItem = ({ _id, removable, notifications, watched, ...props }) => { return ( { - 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 ), []); 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 ( - ); }); diff --git a/src/components/MetaItem/styles.less b/src/components/MetaItem/styles.less index 359525b28..8941b3dbb 100644 --- a/src/components/MetaItem/styles.less +++ b/src/components/MetaItem/styles.less @@ -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; } \ No newline at end of file diff --git a/src/routes/Board/Board.js b/src/routes/Board/Board.js index 85335b986..4994f45dc 100644 --- a/src/routes/Board/Board.js +++ b/src/routes/Board/Board.js @@ -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 ( ); @@ -71,19 +75,9 @@ const Board = () => { ( - - )), - }} - // 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 diff --git a/src/routes/Board/useMetaDetailsForMetaItem.js b/src/routes/Board/useMetaDetailsForMetaItem.js index c848052d2..53f978616 100644 --- a/src/routes/Board/useMetaDetailsForMetaItem.js +++ b/src/routes/Board/useMetaDetailsForMetaItem.js @@ -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; };