// Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); const PropTypes = require('prop-types'); const classnames = require('classnames'); const debounce = require('lodash.debounce'); const langs = require('langs'); const { useTranslation } = require('react-i18next'); const { useRouteFocused } = require('stremio-router'); const { useServices } = require('stremio/services'); const { onFileDrop, useSettings, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell } = require('stremio/common'); const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components'); const BufferingLoader = require('./BufferingLoader'); const VolumeChangeIndicator = require('./VolumeChangeIndicator'); const Error = require('./Error'); const ControlBar = require('./ControlBar'); const NextVideoPopup = require('./NextVideoPopup'); const StatisticsMenu = require('./StatisticsMenu'); const OptionsMenu = require('./OptionsMenu'); const SubtitlesMenu = require('./SubtitlesMenu'); const { default: AudioMenu } = require('./AudioMenu'); const SpeedMenu = require('./SpeedMenu'); const { default: SideDrawerButton } = require('./SideDrawerButton'); const { default: SideDrawer } = require('./SideDrawer'); const usePlayer = require('./usePlayer'); const useStatistics = require('./useStatistics'); const useVideo = require('./useVideo'); const styles = require('./styles'); const Video = require('./Video'); const Player = ({ urlParams, queryParams }) => { const { t } = useTranslation(); const services = useServices(); const shell = useShell(); const forceTranscoding = React.useMemo(() => { return queryParams.has('forceTranscoding'); }, [queryParams]); const [player, videoParamsChanged, timeChanged, seek, pausedChanged, ended, nextVideo] = usePlayer(urlParams); const [settings, updateSettings] = useSettings(); const streamingServer = useStreamingServer(); const statistics = useStatistics(player, streamingServer); const video = useVideo(); const routeFocused = useRouteFocused(); const toast = useToast(); const [seeking, setSeeking] = React.useState(false); const [casting, setCasting] = React.useState(() => { return services.chromecast.active && services.chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED; }); const playbackDevices = React.useMemo(() => streamingServer.playbackDevices !== null && streamingServer.playbackDevices.type === 'Ready' ? streamingServer.playbackDevices.content : [], [streamingServer]); const bufferingRef = React.useRef(); const errorRef = React.useRef(); const [immersed, setImmersed] = React.useState(true); const setImmersedDebounced = React.useCallback(debounce(setImmersed, 3000), []); const [, , , toggleFullscreen] = useFullscreen(); const [optionsMenuOpen, , closeOptionsMenu, toggleOptionsMenu] = useBinaryState(false); const [subtitlesMenuOpen, , closeSubtitlesMenu, toggleSubtitlesMenu] = useBinaryState(false); const [audioMenuOpen, , closeAudioMenu, toggleAudioMenu] = useBinaryState(false); const [speedMenuOpen, , closeSpeedMenu, toggleSpeedMenu] = useBinaryState(false); const [statisticsMenuOpen, , closeStatisticsMenu, toggleStatisticsMenu] = useBinaryState(false); const [nextVideoPopupOpen, openNextVideoPopup, closeNextVideoPopup] = useBinaryState(false); const [sideDrawerOpen, , closeSideDrawer, toggleSideDrawer] = useBinaryState(false); const menusOpen = React.useMemo(() => { return optionsMenuOpen || subtitlesMenuOpen || audioMenuOpen || speedMenuOpen || statisticsMenuOpen || sideDrawerOpen; }, [optionsMenuOpen, subtitlesMenuOpen, audioMenuOpen, speedMenuOpen, statisticsMenuOpen, sideDrawerOpen]); const closeMenus = React.useCallback(() => { closeOptionsMenu(); closeSubtitlesMenu(); closeAudioMenu(); closeSpeedMenu(); closeStatisticsMenu(); closeSideDrawer(); }, []); const overlayHidden = React.useMemo(() => { return immersed && !casting && video.state.paused !== null && !video.state.paused && !menusOpen && !nextVideoPopupOpen; }, [immersed, casting, video.state.paused, menusOpen, nextVideoPopupOpen]); const nextVideoHandledRef = React.useRef(false); const nextVideoPopupDismissed = React.useRef(false); const nextVideoInitialData = React.useRef(player.nextVideo); nextVideoInitialData.current = player.nextVideo; const defaultSubtitlesSelected = React.useRef(false); const defaultAudioTrackSelected = React.useRef(false); const [error, setError] = React.useState(null); const onImplementationChanged = React.useCallback(() => { video.setProp('subtitlesSize', settings.subtitlesSize); video.setProp('subtitlesOffset', settings.subtitlesOffset); video.setProp('subtitlesTextColor', settings.subtitlesTextColor); video.setProp('subtitlesBackgroundColor', settings.subtitlesBackgroundColor); video.setProp('subtitlesOutlineColor', settings.subtitlesOutlineColor); video.setProp('extraSubtitlesSize', settings.subtitlesSize); video.setProp('extraSubtitlesOffset', settings.subtitlesOffset); video.setProp('extraSubtitlesTextColor', settings.subtitlesTextColor); video.setProp('extraSubtitlesBackgroundColor', settings.subtitlesBackgroundColor); video.setProp('extraSubtitlesOutlineColor', settings.subtitlesOutlineColor); }, [settings.subtitlesSize, settings.subtitlesOffset, settings.subtitlesTextColor, settings.subtitlesBackgroundColor, settings.subtitlesOutlineColor]); const onEnded = React.useCallback(() => { player.nextVideo = nextVideoInitialData.current; ended(); if (player.nextVideo !== null) { onNextVideoRequested(); } else { window.history.back(); } }, [player.nextVideo, onNextVideoRequested]); const onError = React.useCallback((error) => { console.error('Player', error); if (error.critical) { setError(error); } else { toast.show({ type: 'error', title: t('ERROR'), message: error.message, timeout: 3000 }); } }, []); const onSubtitlesTrackLoaded = React.useCallback(() => { toast.show({ type: 'success', title: t('PLAYER_SUBTITLES_LOADED'), message: t('PLAYER_SUBTITLES_LOADED_EMBEDDED'), timeout: 3000 }); }, []); const onExtraSubtitlesTrackLoaded = React.useCallback((track) => { toast.show({ type: 'success', title: t('PLAYER_SUBTITLES_LOADED'), message: track.exclusive ? t('PLAYER_SUBTITLES_LOADED_EXCLUSIVE') : track.local ? t('PLAYER_SUBTITLES_LOADED_LOCAL') : t('PLAYER_SUBTITLES_LOADED_ORIGIN', { origin: track.origin }), timeout: 3000 }); }, []); const onExtraSubtitlesTrackAdded = React.useCallback((track) => { if (track.local) { video.setExtraSubtitlesTrack(track.id); } }, []); const onPlayRequested = React.useCallback(() => { video.setProp('paused', false); setSeeking(false); }, []); const onPlayRequestedDebounced = React.useCallback(debounce(onPlayRequested, 200), []); const onPauseRequested = React.useCallback(() => { video.setProp('paused', true); }, []); const onPauseRequestedDebounced = React.useCallback(debounce(onPauseRequested, 200), []); const onMuteRequested = React.useCallback(() => { video.setProp('muted', true); }, []); const onUnmuteRequested = React.useCallback(() => { video.setProp('muted', false); }, []); const onVolumeChangeRequested = React.useCallback((volume) => { video.setProp('volume', volume); }, []); const onSeekRequested = React.useCallback((time) => { video.setProp('time', time); seek(time, video.state.duration, video.state.manifest?.name); }, [video.state.duration, video.state.manifest]); const onPlaybackSpeedChanged = React.useCallback((rate) => { video.setProp('playbackSpeed', rate); }, []); const onSubtitlesTrackSelected = React.useCallback((id) => { video.setSubtitlesTrack(id); }, []); const onExtraSubtitlesTrackSelected = React.useCallback((id) => { video.setExtraSubtitlesTrack(id); }, []); const onAudioTrackSelected = React.useCallback((id) => { video.setProp('selectedAudioTrackId', id); }, []); const onExtraSubtitlesDelayChanged = React.useCallback((delay) => { video.setProp('extraSubtitlesDelay', delay); }, []); const onSubtitlesSizeChanged = React.useCallback((size) => { updateSettings({ subtitlesSize: size }); }, [updateSettings]); const onSubtitlesOffsetChanged = React.useCallback((offset) => { updateSettings({ subtitlesOffset: offset }); }, [updateSettings]); const onDismissNextVideoPopup = React.useCallback(() => { closeNextVideoPopup(); nextVideoPopupDismissed.current = true; }, []); const onNextVideoRequested = React.useCallback(() => { if (player.nextVideo !== null) { nextVideo(); const deepLinks = player.nextVideo.deepLinks; if (deepLinks.metaDetailsStreams && deepLinks.player) { window.location.replace(deepLinks.metaDetailsStreams); window.location.href = deepLinks.player; } else { window.location.replace(deepLinks.player ?? deepLinks.metaDetailsStreams); } } }, [player.nextVideo]); const onVideoClick = React.useCallback(() => { if (video.state.paused !== null) { if (video.state.paused) { onPlayRequestedDebounced(); } else { onPauseRequestedDebounced(); } } }, [video.state.paused]); const onVideoDoubleClick = React.useCallback(() => { onPlayRequestedDebounced.cancel(); onPauseRequestedDebounced.cancel(); toggleFullscreen(); }, [toggleFullscreen]); const onContainerMouseDown = React.useCallback((event) => { if (!event.nativeEvent.optionsMenuClosePrevented) { closeOptionsMenu(); } if (!event.nativeEvent.subtitlesMenuClosePrevented) { closeSubtitlesMenu(); } if (!event.nativeEvent.audioMenuClosePrevented) { closeAudioMenu(); } if (!event.nativeEvent.speedMenuClosePrevented) { closeSpeedMenu(); } if (!event.nativeEvent.statisticsMenuClosePrevented) { closeStatisticsMenu(); } closeSideDrawer(); }, []); const onContainerMouseMove = React.useCallback((event) => { setImmersed(false); if (!event.nativeEvent.immersePrevented) { setImmersedDebounced(true); } else { setImmersedDebounced.cancel(); } }, []); const onContainerMouseLeave = React.useCallback(() => { setImmersedDebounced.cancel(); setImmersed(true); }, []); const onBarMouseMove = React.useCallback((event) => { event.nativeEvent.immersePrevented = true; }, []); onFileDrop(CONSTANTS.SUPPORTED_LOCAL_SUBTITLES, async (filename, buffer) => { video.addLocalSubtitles(filename, buffer); }); React.useEffect(() => { setError(null); video.unload(); if (player.selected && streamingServer.settings?.type !== 'Loading') { video.load({ stream: { ...player.selected.stream, subtitles: Array.isArray(player.selected.stream.subtitles) ? player.selected.stream.subtitles.map((subtitles) => ({ ...subtitles, label: subtitles.url })) : [] }, autoplay: true, time: player.libraryItem !== null && player.selected.streamRequest !== null && player.selected.streamRequest.path !== null && player.libraryItem.state.video_id === player.selected.streamRequest.path.id ? player.libraryItem.state.timeOffset : 0, forceTranscoding: forceTranscoding || casting, maxAudioChannels: settings.surroundSound ? 32 : 2, hardwareDecoding: settings.hardwareDecoding, streamingServerURL: streamingServer.baseUrl ? casting ? streamingServer.baseUrl : streamingServer.selected.transportUrl : null, seriesInfo: player.seriesInfo, }, { chromecastTransport: services.chromecast.active ? services.chromecast.transport : null, shellTransport: services.shell.active ? services.shell.transport : null, }); } }, [streamingServer.baseUrl, player.selected, forceTranscoding, casting]); React.useEffect(() => { if (video.state.stream !== null) { const tracks = player.subtitles.map((subtitles) => ({ ...subtitles, label: subtitles.url })); video.addExtraSubtitlesTracks(tracks); } }, [player.subtitles, video.state.stream]); React.useEffect(() => { video.setProp('subtitlesSize', settings.subtitlesSize); video.setProp('extraSubtitlesSize', settings.subtitlesSize); }, [settings.subtitlesSize]); React.useEffect(() => { video.setProp('subtitlesOffset', settings.subtitlesOffset); video.setProp('extraSubtitlesOffset', settings.subtitlesOffset); }, [settings.subtitlesOffset]); React.useEffect(() => { video.setProp('subtitlesTextColor', settings.subtitlesTextColor); video.setProp('extraSubtitlesTextColor', settings.subtitlesTextColor); }, [settings.subtitlesTextColor]); React.useEffect(() => { video.setProp('subtitlesBackgroundColor', settings.subtitlesBackgroundColor); video.setProp('extraSubtitlesBackgroundColor', settings.subtitlesBackgroundColor); }, [settings.subtitlesBackgroundColor]); React.useEffect(() => { video.setProp('subtitlesOutlineColor', settings.subtitlesOutlineColor); video.setProp('extraSubtitlesOutlineColor', settings.subtitlesOutlineColor); }, [settings.subtitlesOutlineColor]); React.useEffect(() => { !seeking && timeChanged(video.state.time, video.state.duration, video.state.manifest?.name); }, [video.state.time, video.state.duration, video.state.manifest, seeking]); React.useEffect(() => { if (video.state.paused !== null) { pausedChanged(video.state.paused); } }, [video.state.paused]); React.useEffect(() => { videoParamsChanged(video.state.videoParams); }, [video.state.videoParams]); React.useEffect(() => { if (!!settings.bingeWatching && player.nextVideo !== null && !nextVideoPopupDismissed.current) { if (video.state.time !== null && video.state.duration !== null && video.state.time < video.state.duration && (video.state.duration - video.state.time) <= settings.nextVideoNotificationDuration) { openNextVideoPopup(); } else { closeNextVideoPopup(); } } }, [player.nextVideo, video.state.time, video.state.duration]); React.useEffect(() => { if (!defaultSubtitlesSelected.current) { const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang); const subtitlesTrack = findTrackByLang(video.state.subtitlesTracks, settings.subtitlesLanguage); const extraSubtitlesTrack = findTrackByLang(video.state.extraSubtitlesTracks, settings.subtitlesLanguage); if (subtitlesTrack && subtitlesTrack.id) { onSubtitlesTrackSelected(subtitlesTrack.id); defaultSubtitlesSelected.current = true; } else if (extraSubtitlesTrack && extraSubtitlesTrack.id) { onExtraSubtitlesTrackSelected(extraSubtitlesTrack.id); defaultSubtitlesSelected.current = true; } } }, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks]); React.useEffect(() => { if (!defaultAudioTrackSelected.current) { const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang); const audioTrack = findTrackByLang(video.state.audioTracks, settings.audioLanguage); if (audioTrack && audioTrack.id) { onAudioTrackSelected(audioTrack.id); defaultAudioTrackSelected.current = true; } } }, [video.state.audioTracks]); React.useEffect(() => { defaultSubtitlesSelected.current = false; defaultAudioTrackSelected.current = false; nextVideoPopupDismissed.current = false; nextVideoHandledRef.current = false; }, [video.state.stream]); React.useEffect(() => { if ((!Array.isArray(video.state.subtitlesTracks) || video.state.subtitlesTracks.length === 0) && (!Array.isArray(video.state.extraSubtitlesTracks) || video.state.extraSubtitlesTracks.length === 0)) { closeSubtitlesMenu(); } }, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks]); React.useEffect(() => { if (!Array.isArray(video.state.audioTracks) || video.state.audioTracks.length === 0) { closeAudioMenu(); } }, [video.state.audioTracks]); React.useEffect(() => { if (video.state.playbackSpeed === null) { closeSpeedMenu(); } }, [video.state.playbackSpeed]); React.useEffect(() => { const toastFilter = (item) => item?.dataset?.type === 'CoreEvent'; toast.addFilter(toastFilter); const onCastStateChange = () => { setCasting(services.chromecast.active && services.chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED); }; const onChromecastServiceStateChange = () => { onCastStateChange(); if (services.chromecast.active) { services.chromecast.transport.on( cast.framework.CastContextEventType.CAST_STATE_CHANGED, onCastStateChange ); } }; const onCoreEvent = ({ event }) => { if (event === 'PlayingOnDevice') { onPauseRequested(); } }; services.chromecast.on('stateChanged', onChromecastServiceStateChange); services.core.transport.on('CoreEvent', onCoreEvent); onChromecastServiceStateChange(); return () => { toast.removeFilter(toastFilter); services.chromecast.off('stateChanged', onChromecastServiceStateChange); services.core.transport.off('CoreEvent', onCoreEvent); if (services.chromecast.active) { services.chromecast.transport.off( cast.framework.CastContextEventType.CAST_STATE_CHANGED, onCastStateChange ); } }; }, []); React.useEffect(() => { if (settings.pauseOnMinimize && (shell.windowClosed || shell.windowHidden)) { onPauseRequested(); } }, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]); React.useEffect(() => { nextVideoHandledRef.current = false; }, [player.selected]); React.useEffect(() => { video.events.on('error', onError); video.events.on('ended', onEnded); video.events.on('subtitlesTrackLoaded', onSubtitlesTrackLoaded); video.events.on('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded); video.events.on('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded); video.events.on('implementationChanged', onImplementationChanged); return () => { video.events.off('error', onError); video.events.off('ended', onEnded); video.events.off('subtitlesTrackLoaded', onSubtitlesTrackLoaded); video.events.off('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded); video.events.off('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded); video.events.off('implementationChanged', onImplementationChanged); }; }, []); React.useLayoutEffect(() => { const onKeyDown = (event) => { switch (event.code) { case 'Space': { if (!menusOpen && !nextVideoPopupOpen && video.state.paused !== null) { if (video.state.paused) { onPlayRequested(); setSeeking(false); } else { onPauseRequested(); } } break; } case 'ArrowRight': { if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) { const seekDuration = event.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration; setSeeking(true); onSeekRequested(video.state.time + seekDuration); } break; } case 'ArrowLeft': { if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) { const seekDuration = event.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration; setSeeking(true); onSeekRequested(video.state.time - seekDuration); } break; } case 'ArrowUp': { if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) { onVolumeChangeRequested(Math.min(video.state.volume + 5, 200)); } break; } case 'ArrowDown': { if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) { onVolumeChangeRequested(Math.max(video.state.volume - 5, 0)); } break; } case 'KeyS': { closeMenus(); if ((Array.isArray(video.state.subtitlesTracks) && video.state.subtitlesTracks.length > 0) || (Array.isArray(video.state.extraSubtitlesTracks) && video.state.extraSubtitlesTracks.length > 0)) { toggleSubtitlesMenu(); } break; } case 'KeyA': { closeMenus(); if (Array.isArray(video.state.audioTracks) && video.state.audioTracks.length > 0) { toggleAudioMenu(); } break; } case 'KeyI': { closeMenus(); if (player.metaItem !== null && player.metaItem.type === 'Ready') { toggleSideDrawer(); } break; } case 'KeyR': { closeMenus(); if (video.state.playbackSpeed !== null) { toggleSpeedMenu(); } break; } case 'KeyD': { closeMenus(); if (streamingServer.statistics !== null && streamingServer.statistics.type !== 'Err' && player.selected && typeof player.selected.stream.infoHash === 'string' && typeof player.selected.stream.fileIdx === 'number') { toggleStatisticsMenu(); } break; } case 'Escape': { closeMenus(); !settings.escExitFullscreen && window.history.back(); break; } } }; const onKeyUp = (event) => { if (event.code === 'ArrowRight' || event.code === 'ArrowLeft') { setSeeking(false); } }; const onWheel = ({ deltaY }) => { if (menusOpen || video.state.volume === null) return; if (deltaY > 0) { onVolumeChangeRequested(Math.max(video.state.volume - 5, 0)); } else { if (video.state.volume < 100) { onVolumeChangeRequested(Math.min(video.state.volume + 5, 100)); } } }; if (routeFocused) { window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); window.addEventListener('wheel', onWheel); } return () => { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); window.removeEventListener('wheel', onWheel); }; }, [player.metaItem, player.selected, streamingServer.statistics, settings.seekTimeDuration, settings.seekShortTimeDuration, settings.escExitFullscreen, routeFocused, menusOpen, nextVideoPopupOpen, video.state.paused, video.state.time, video.state.volume, video.state.audioTracks, video.state.subtitlesTracks, video.state.extraSubtitlesTracks, video.state.playbackSpeed, toggleSubtitlesMenu, toggleStatisticsMenu, toggleSideDrawer]); React.useLayoutEffect(() => { return () => { setImmersedDebounced.cancel(); onPlayRequestedDebounced.cancel(); onPauseRequestedDebounced.cancel(); }; }, []); return (