diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index 73523bb0b..30a835ef1 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -9,7 +9,7 @@ const { useTranslation } = require('react-i18next'); const { useRouteFocused } = require('stremio-router'); const { useServices, useGamepad } = require('stremio/services'); const { useContentGamepadNavigation } = require('stremio/services/GamepadNavigation'); -const { onFileDrop, useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell, usePlatform, onShortcut } = require('stremio/common'); +const { useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, useShell, usePlatform, onShortcut } = require('stremio/common'); const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components'); const BufferingLoader = require('./BufferingLoader'); const VolumeChangeIndicator = require('./VolumeChangeIndicator'); @@ -26,6 +26,7 @@ const { default: SideDrawer } = require('./SideDrawer'); const usePlayer = require('./usePlayer'); const useStatistics = require('./useStatistics'); const useVideo = require('./useVideo'); +const { default: useSubtitles } = require('./useSubtitles'); const styles = require('./styles'); const Video = require('./Video'); const { default: Indicator } = require('./Indicator/Indicator'); @@ -90,13 +91,28 @@ const Player = ({ urlParams, queryParams }) => { closeSideDrawer(); }, []); + const { + streamSubtitles, + allSubtitleTracks, + extraSubtitleTracks, + selectedExtraSubtitleTrackId, + subtitlesMenuProps, + } = useSubtitles({ + player, + video, + settings, + streamStateChanged, + menusOpen, + closeMenus, + closeSubtitlesMenu, + toggleSubtitlesMenu, + }); + const overlayHidden = React.useMemo(() => { return immersed && !casting && video.state.paused !== null && !video.state.paused && !menusOpen; }, [immersed, casting, video.state.paused, menusOpen]); const nextVideoPopupDismissed = React.useRef(false); - const defaultSubtitlesSelected = React.useRef(false); - const lastSubtitleTrack = React.useRef(null); const defaultAudioTrackSelected = React.useRef(false); const playingOnExternalDevice = React.useRef(false); const [error, setError] = React.useState(null); @@ -113,14 +129,6 @@ const Player = ({ urlParams, queryParams }) => { const HOLD_DELAY = 200; - const onImplementationChanged = React.useCallback(() => { - video.setSubtitlesSize(settings.subtitlesSize); - video.setSubtitlesOffset(settings.subtitlesOffset); - video.setSubtitlesTextColor(settings.subtitlesTextColor); - video.setSubtitlesBackgroundColor(settings.subtitlesBackgroundColor); - video.setSubtitlesOutlineColor(settings.subtitlesOutlineColor); - }, [settings]); - const handleNextVideoNavigation = React.useCallback((deepLinks, bingeWatching, ended) => { if (ended) { if (bingeWatching) { @@ -178,33 +186,6 @@ const Player = ({ urlParams, queryParams }) => { } }, []); - 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(() => { playingOnExternalDevice.current = false; video.setPaused(false); @@ -251,28 +232,6 @@ const Player = ({ urlParams, queryParams }) => { video.setVideoScale(nextScale); }, [video.state.videoScale]); - const onSubtitlesTrackSelected = React.useCallback((track) => { - defaultSubtitlesSelected.current = true; - video.setSubtitlesTrack(track?.id ?? null); - if (track) { - lastSubtitleTrack.current = { id: track.id, embedded: true }; - } - streamStateChanged({ - subtitleTrack: track ? { id: track.id, embedded: true, lang: track.lang } : null, - }); - }, [streamStateChanged]); - - const onExtraSubtitlesTrackSelected = React.useCallback((track) => { - defaultSubtitlesSelected.current = true; - video.setExtraSubtitlesTrack(track?.id ?? null); - if (track) { - lastSubtitleTrack.current = { id: track.id, embedded: false }; - } - streamStateChanged({ - subtitleTrack: track ? { id: track.id, embedded: false, lang: track.lang } : null, - }); - }, [streamStateChanged]); - const onAudioTrackSelected = React.useCallback((id) => { video.setAudioTrack(id); streamStateChanged({ @@ -282,37 +241,6 @@ const Player = ({ urlParams, queryParams }) => { }); }, [streamStateChanged]); - const onExtraSubtitlesDelayChanged = React.useCallback((delay) => { - video.setSubtitlesDelay(delay); - streamStateChanged({ subtitleDelay: delay }); - }, [streamStateChanged]); - - const onIncreaseSubtitlesDelay = React.useCallback(() => { - const delay = video.state.extraSubtitlesDelay + 250; - onExtraSubtitlesDelayChanged(delay); - }, [video.state.extraSubtitlesDelay, onExtraSubtitlesDelayChanged]); - - const onDecreaseSubtitlesDelay = React.useCallback(() => { - const delay = video.state.extraSubtitlesDelay - 250; - onExtraSubtitlesDelayChanged(delay); - }, [video.state.extraSubtitlesDelay, onExtraSubtitlesDelayChanged]); - - const onSubtitlesSizeChanged = React.useCallback((size) => { - video.setSubtitlesSize(size); - streamStateChanged({ subtitleSize: size }); - }, [streamStateChanged]); - - const onUpdateSubtitlesSize = React.useCallback((delta) => { - const sizeIndex = CONSTANTS.SUBTITLES_SIZES.indexOf(video.state.subtitlesSize); - const size = CONSTANTS.SUBTITLES_SIZES[Math.max(0, Math.min(CONSTANTS.SUBTITLES_SIZES.length - 1, sizeIndex + delta))]; - onSubtitlesSizeChanged(size); - }, [video.state.subtitlesSize, onSubtitlesSizeChanged]); - - const onSubtitlesOffsetChanged = React.useCallback((offset) => { - video.setSubtitlesOffset(offset); - streamStateChanged({ subtitleOffset: offset }); - }, [streamStateChanged]); - const onDismissNextVideoPopup = React.useCallback(() => { closeNextVideoPopup(); nextVideoPopupDismissed.current = true; @@ -381,10 +309,6 @@ const Player = ({ urlParams, queryParams }) => { event.nativeEvent.immersePrevented = true; }, []); - onFileDrop(CONSTANTS.SUPPORTED_LOCAL_SUBTITLES, async (filename, buffer) => { - video.addLocalSubtitles(filename, buffer); - }); - const onPlayPause = React.useCallback(() => { if (!menusOpen && !nextVideoPopupOpen && video.state.paused !== null) { if (video.state.paused) { @@ -466,13 +390,7 @@ const Player = ({ urlParams, queryParams }) => { video.load({ stream: { ...player.stream.content, - subtitles: Array.isArray(player.selected.stream.subtitles) ? - player.selected.stream.subtitles.map((subtitles) => ({ - ...subtitles, - label: subtitles.label || subtitles.url - })) - : - [] + subtitles: streamSubtitles }, autoplay: true, time: player.libraryItem !== null && @@ -501,16 +419,7 @@ const Player = ({ urlParams, queryParams }) => { shellTransport: services.shell.active ? services.shell.transport : null, }); } - }, [streamingServer.baseUrl, player.selected, player.stream, forceTranscoding, casting]); - React.useEffect(() => { - if (video.state.stream !== null) { - const tracks = player.subtitles.map((subtitles) => ({ - ...subtitles, - label: subtitles.label || subtitles.url - })); - video.addExtraSubtitlesTracks(tracks); - } - }, [player.subtitles, video.state.stream]); + }, [streamingServer.baseUrl, player.selected, player.stream, streamSubtitles, forceTranscoding, casting]); React.useEffect(() => { !seeking && timeChanged(video.state.time, video.state.duration, video.state.manifest?.name); @@ -546,48 +455,6 @@ const Player = ({ urlParams, queryParams }) => { } }, [player.nextVideo, video.state.time, video.state.duration]); - // Auto subtitles track selection - React.useEffect(() => { - if (!defaultSubtitlesSelected.current) { - if (settings.subtitlesLanguage === null) { - video.setSubtitlesTrack(null); - video.setExtraSubtitlesTrack(null); - defaultSubtitlesSelected.current = true; - return; - } - - const savedTrackId = player.streamState?.subtitleTrack?.id; - const savedLang = player.streamState?.subtitleTrack?.lang; - const savedIsExternal = savedTrackId && player.streamState?.subtitleTrack?.embedded === false; - - const subtitlesTrack = - savedTrackId ? findTrackById(video.state.subtitlesTracks, savedTrackId) : - savedLang ? findTrackByLang(video.state.subtitlesTracks, savedLang) : - findTrackByLang(video.state.subtitlesTracks, settings.subtitlesLanguage); - - const extraSubtitlesTrack = - savedTrackId ? findTrackById(video.state.extraSubtitlesTracks, savedTrackId) : - savedLang ? findTrackByLang(video.state.extraSubtitlesTracks, savedLang) : - findTrackByLang(video.state.extraSubtitlesTracks, settings.subtitlesLanguage); - - if (subtitlesTrack && subtitlesTrack.id) { - if (video.state.selectedSubtitlesTrackId !== subtitlesTrack.id || - video.state.selectedExtraSubtitlesTrackId !== null) { - video.setSubtitlesTrack(subtitlesTrack.id); - } - defaultSubtitlesSelected.current = true; - } else if (extraSubtitlesTrack && extraSubtitlesTrack.id) { - if (video.state.selectedExtraSubtitlesTrackId !== extraSubtitlesTrack.id || - video.state.selectedSubtitlesTrackId !== null) { - video.setExtraSubtitlesTrack(extraSubtitlesTrack.id); - } - if (savedIsExternal) { - defaultSubtitlesSelected.current = true; - } - } - } - }, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks, video.state.selectedSubtitlesTrackId, video.state.selectedExtraSubtitlesTrackId, player.streamState]); - // Auto audio track selection React.useEffect(() => { if (!defaultAudioTrackSelected.current) { @@ -603,30 +470,8 @@ const Player = ({ urlParams, queryParams }) => { } }, [video.state.audioTracks, player.streamState]); - // Saved subtitles settings React.useEffect(() => { - if (video.state.stream !== null) { - const delay = player.streamState?.subtitleDelay; - if (typeof delay === 'number') { - video.setSubtitlesDelay(delay); - } - - const size = player.streamState?.subtitleSize; - if (typeof size === 'number') { - video.setSubtitlesSize(size); - } - - const offset = player.streamState?.subtitleOffset; - if (typeof offset === 'number') { - video.setSubtitlesOffset(offset); - } - } - }, [video.state.stream, player.streamState]); - - React.useEffect(() => { - defaultSubtitlesSelected.current = false; defaultAudioTrackSelected.current = false; - lastSubtitleTrack.current = null; nextVideoPopupDismissed.current = false; playingOnExternalDevice.current = false; // we need a timeout here to make sure that previous page unloads and the new one loads @@ -634,13 +479,6 @@ const Player = ({ urlParams, queryParams }) => { setTimeout(() => isNavigating.current = false, 1000); }, [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(); @@ -753,40 +591,6 @@ const Player = ({ urlParams, queryParams }) => { } }, [video.state.volume], !menusOpen); - onShortcut('subtitlesDelay', (combo) => { - combo === 1 ? onIncreaseSubtitlesDelay() : onDecreaseSubtitlesDelay(); - }, [onIncreaseSubtitlesDelay, onDecreaseSubtitlesDelay], !menusOpen); - - onShortcut('subtitlesSize', (combo) => { - combo === 1 ? onUpdateSubtitlesSize(1) : onUpdateSubtitlesSize(-1); - }, [onUpdateSubtitlesSize, onUpdateSubtitlesSize], !menusOpen); - - onShortcut('toggleSubtitles', () => { - const isEnabled = video.state.selectedSubtitlesTrackId !== null || video.state.selectedExtraSubtitlesTrackId !== null; - - if (isEnabled) { - if (video.state.selectedSubtitlesTrackId) { - lastSubtitleTrack.current = { id: video.state.selectedSubtitlesTrackId, embedded: true }; - } else if (video.state.selectedExtraSubtitlesTrackId) { - lastSubtitleTrack.current = { id: video.state.selectedExtraSubtitlesTrackId, embedded: false }; - } - video.setSubtitlesTrack(null); - video.setExtraSubtitlesTrack(null); - } else { - const savedTrack = player.streamState?.subtitleTrack ?? lastSubtitleTrack.current; - if (savedTrack?.id) { - savedTrack.embedded ? video.setSubtitlesTrack(savedTrack.id) : video.setExtraSubtitlesTrack(savedTrack.id); - } - } - }, [player.streamState, video.state.selectedSubtitlesTrackId, video.state.selectedExtraSubtitlesTrackId], !menusOpen); - - onShortcut('subtitlesMenu', () => { - closeMenus(); - if (video.state?.subtitlesTracks?.length > 0 || video.state?.extraSubtitlesTracks?.length > 0) { - toggleSubtitlesMenu(); - } - }, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks, toggleSubtitlesMenu]); - onShortcut('audioMenu', () => { closeMenus(); if (video.state?.audioTracks?.length > 0) { @@ -951,18 +755,10 @@ const Player = ({ urlParams, queryParams }) => { 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); }; }, []); @@ -1035,8 +831,8 @@ const Player = ({ urlParams, queryParams }) => { className={classnames(styles['layer'], styles['menu-layer'])} stream={player?.selected?.stream} playbackDevices={playbackDevices} - extraSubtitlesTracks={video.state.extraSubtitlesTracks} - selectedExtraSubtitlesTrackId={video.state.selectedExtraSubtitlesTrackId} + extraSubtitlesTracks={extraSubtitleTracks} + selectedExtraSubtitlesTrackId={selectedExtraSubtitleTrackId} /> { volume={video.state.volume} muted={video.state.muted} playbackSpeed={video.state.playbackSpeed} - subtitlesTracks={video.state.subtitlesTracks.concat(video.state.extraSubtitlesTracks)} + subtitlesTracks={allSubtitleTracks} audioTracks={video.state.audioTracks} metaItem={player.metaItem} nextVideo={player.nextVideo} @@ -1128,24 +924,7 @@ const Player = ({ urlParams, queryParams }) => { @@ -1168,8 +947,8 @@ const Player = ({ urlParams, queryParams }) => { className={classnames(styles['layer'], styles['menu-layer'])} stream={player.selected?.stream} playbackDevices={playbackDevices} - extraSubtitlesTracks={video.state.extraSubtitlesTracks} - selectedExtraSubtitlesTrackId={video.state.selectedExtraSubtitlesTrackId} + extraSubtitlesTracks={extraSubtitleTracks} + selectedExtraSubtitlesTrackId={selectedExtraSubtitleTrackId} /> diff --git a/src/routes/Player/useSubtitles.d.ts b/src/routes/Player/useSubtitles.d.ts new file mode 100644 index 000000000..93084e7b2 --- /dev/null +++ b/src/routes/Player/useSubtitles.d.ts @@ -0,0 +1,92 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +type SubtitleTrack = { + id: string, + lang: string, + label?: string | null, + origin?: string, + url?: string | null, + fallbackUrl?: string | null, + embedded?: boolean, + local?: boolean, + exclusive?: boolean, + buffer?: ArrayBuffer, +}; + +type SelectedSubtitleTrack = { + id: string, + embedded: boolean, +}; + +type VideoSubtitleState = { + stream: unknown | null, + subtitlesTracks: SubtitleTrack[], + selectedSubtitlesTrackId: string | null, + subtitlesOffset: number | null, + subtitlesSize: number | null, + extraSubtitlesTracks: SubtitleTrack[], + selectedExtraSubtitlesTrackId: string | null, + extraSubtitlesOffset: number | null, + extraSubtitlesDelay: number | null, + extraSubtitlesSize: number | null, +}; + +type VideoEvents = { + on: (event: string, listener: (...args: any[]) => void) => void, + off: (event: string, listener: (...args: any[]) => void) => void, +}; + +type VideoController = { + events: VideoEvents, + state: VideoSubtitleState, + addExtraSubtitlesTracks: (tracks: SubtitleTrack[]) => void, + addLocalSubtitles: (filename: string, buffer: ArrayBuffer) => void, + setSubtitlesTrack: (id: string | null) => void, + setExtraSubtitlesTrack: (id: string | null) => void, + setSubtitlesDelay: (delay: number) => void, + setSubtitlesSize: (size: number) => void, + setSubtitlesOffset: (offset: number) => void, + setSubtitlesTextColor: (color: string) => void, + setSubtitlesBackgroundColor: (color: string) => void, + setSubtitlesOutlineColor: (color: string) => void, +}; + +type UseSubtitlesArgs = { + player: Player, + video: VideoController, + settings: Settings, + streamStateChanged: (state: Partial) => void, + menusOpen: boolean, + closeMenus: () => void, + closeSubtitlesMenu: () => void, + toggleSubtitlesMenu: () => void, +}; + +type SubtitlesMenuProps = { + subtitlesLanguage: string | null, + interfaceLanguage: string, + subtitlesTracks: SubtitleTrack[], + selectedSubtitlesTrackId: string | null, + subtitlesOffset: number | null, + subtitlesSize: number | null, + extraSubtitlesTracks: SubtitleTrack[], + selectedExtraSubtitlesTrackId: string | null, + extraSubtitlesOffset: number | null, + extraSubtitlesDelay: number | null, + extraSubtitlesSize: number | null, + onSubtitlesTrackSelected: (track: SubtitleTrack | null) => void, + onExtraSubtitlesTrackSelected: (track: SubtitleTrack | null) => void, + onSubtitlesOffsetChanged: (offset: number) => void, + onSubtitlesSizeChanged: (size: number) => void, + onExtraSubtitlesOffsetChanged: (offset: number) => void, + onExtraSubtitlesDelayChanged: (delay: number) => void, + onExtraSubtitlesSizeChanged: (size: number) => void, +}; + +type UseSubtitlesResult = { + streamSubtitles: SubtitleTrack[], + allSubtitleTracks: SubtitleTrack[], + extraSubtitleTracks: SubtitleTrack[], + selectedExtraSubtitleTrackId: string | null, + subtitlesMenuProps: SubtitlesMenuProps, +}; diff --git a/src/routes/Player/useSubtitles.ts b/src/routes/Player/useSubtitles.ts new file mode 100644 index 000000000..09fb6be78 --- /dev/null +++ b/src/routes/Player/useSubtitles.ts @@ -0,0 +1,391 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CONSTANTS, languages, onFileDrop, onShortcut, useToast } from 'stremio/common'; + +const withFallbackLabels = (tracks?: SubtitleTrack[] | null): SubtitleTrack[] => { + if (!Array.isArray(tracks)) { + return []; + } + + return tracks.map((track) => ({ + ...track, + label: track.label || track.url || '', + })); +}; + +const findTrackById = (tracks: SubtitleTrack[], id?: string | null) => { + if (!id) { + return undefined; + } + + return tracks.find((track) => track.id === id); +}; + +const findTrackByLanguage = (tracks: SubtitleTrack[], language?: string | null) => { + if (!language) { + return undefined; + } + + const languageCode = languages.toCode(language); + + return tracks.find((track) => { + return track.lang === language || languages.toCode(track.lang) === languageCode; + }); +}; + +const useSubtitles = ({ + player, + video, + settings, + streamStateChanged, + menusOpen, + closeMenus, + closeSubtitlesMenu, + toggleSubtitlesMenu, +}: UseSubtitlesArgs): UseSubtitlesResult => { + const { t } = useTranslation(); + const toast = useToast(); + const videoRef = useRef(video); + const settingsRef = useRef(settings); + const defaultTrackSelected = useRef(false); + const lastSelectedTrack = useRef(null); + + videoRef.current = video; + settingsRef.current = settings; + + const streamSubtitles = useMemo(() => { + return withFallbackLabels(player.selected?.stream.subtitles); + }, [player.selected]); + + const externalSubtitles = useMemo(() => { + return withFallbackLabels(player.subtitles); + }, [player.subtitles]); + + const allTracks = useMemo(() => { + return video.state.subtitlesTracks.concat(video.state.extraSubtitlesTracks); + }, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks]); + + const hasTracks = allTracks.length > 0; + + const applySubtitleStyle = useCallback(() => { + const currentSettings = settingsRef.current; + const currentVideo = videoRef.current; + + currentVideo.setSubtitlesSize(currentSettings.subtitlesSize); + currentVideo.setSubtitlesOffset(currentSettings.subtitlesOffset); + currentVideo.setSubtitlesTextColor(currentSettings.subtitlesTextColor); + currentVideo.setSubtitlesBackgroundColor(currentSettings.subtitlesBackgroundColor); + currentVideo.setSubtitlesOutlineColor(currentSettings.subtitlesOutlineColor); + }, []); + + const rememberTrack = useCallback((track: SubtitleTrack, embedded: boolean) => { + lastSelectedTrack.current = { id: track.id, embedded }; + streamStateChanged({ + subtitleTrack: { + id: track.id, + embedded, + lang: track.lang, + }, + }); + }, [streamStateChanged]); + + const disableSubtitles = useCallback(() => { + defaultTrackSelected.current = true; + video.setSubtitlesTrack(null); + video.setExtraSubtitlesTrack(null); + streamStateChanged({ subtitleTrack: null }); + }, [streamStateChanged, video]); + + const selectEmbeddedTrack = useCallback((track: SubtitleTrack | null) => { + if (!track) { + disableSubtitles(); + return; + } + + defaultTrackSelected.current = true; + video.setSubtitlesTrack(track.id); + rememberTrack(track, true); + }, [disableSubtitles, rememberTrack, video]); + + const selectExtraTrack = useCallback((track: SubtitleTrack | null) => { + if (!track) { + disableSubtitles(); + return; + } + + defaultTrackSelected.current = true; + video.setExtraSubtitlesTrack(track.id); + rememberTrack(track, false); + }, [disableSubtitles, rememberTrack, video]); + + const changeDelay = useCallback((delay: number) => { + video.setSubtitlesDelay(delay); + streamStateChanged({ subtitleDelay: delay }); + }, [streamStateChanged, video]); + + const increaseDelay = useCallback(() => { + changeDelay((video.state.extraSubtitlesDelay ?? 0) + 250); + }, [changeDelay, video.state.extraSubtitlesDelay]); + + const decreaseDelay = useCallback(() => { + changeDelay((video.state.extraSubtitlesDelay ?? 0) - 250); + }, [changeDelay, video.state.extraSubtitlesDelay]); + + const changeSize = useCallback((size: number) => { + video.setSubtitlesSize(size); + streamStateChanged({ subtitleSize: size }); + }, [streamStateChanged, video]); + + const updateSize = useCallback((delta: number) => { + const sizes = CONSTANTS.SUBTITLES_SIZES as number[]; + const sizeIndex = sizes.indexOf(video.state.subtitlesSize ?? -1); + const nextIndex = Math.max(0, Math.min(sizes.length - 1, sizeIndex + delta)); + + changeSize(sizes[nextIndex]); + }, [changeSize, video.state.subtitlesSize]); + + const changeOffset = useCallback((offset: number) => { + video.setSubtitlesOffset(offset); + streamStateChanged({ subtitleOffset: offset }); + }, [streamStateChanged, video]); + + onFileDrop(CONSTANTS.SUPPORTED_LOCAL_SUBTITLES, (filename: string, buffer: ArrayBuffer) => { + videoRef.current.addLocalSubtitles(filename, buffer); + }); + + useEffect(() => { + if (video.state.stream !== null) { + video.addExtraSubtitlesTracks(externalSubtitles); + } + }, [externalSubtitles, video.state.stream]); + + useEffect(() => { + if (defaultTrackSelected.current) { + return; + } + + if (settings.subtitlesLanguage === null) { + video.setSubtitlesTrack(null); + video.setExtraSubtitlesTrack(null); + defaultTrackSelected.current = true; + return; + } + + const savedTrack = player.streamState?.subtitleTrack; + const savedTrackId = savedTrack?.id; + const savedLanguage = savedTrack?.lang; + const savedExternalTrack = Boolean(savedTrackId && savedTrack?.embedded === false); + const embeddedTrack = savedTrackId ? + findTrackById(video.state.subtitlesTracks, savedTrackId) + : + findTrackByLanguage(video.state.subtitlesTracks, savedLanguage ?? settings.subtitlesLanguage); + const extraTrack = savedTrackId ? + findTrackById(video.state.extraSubtitlesTracks, savedTrackId) + : + findTrackByLanguage(video.state.extraSubtitlesTracks, savedLanguage ?? settings.subtitlesLanguage); + + if (embeddedTrack?.id) { + if (video.state.selectedSubtitlesTrackId !== embeddedTrack.id || + video.state.selectedExtraSubtitlesTrackId !== null) { + video.setSubtitlesTrack(embeddedTrack.id); + } + + defaultTrackSelected.current = true; + return; + } + + if (extraTrack?.id) { + if (video.state.selectedExtraSubtitlesTrackId !== extraTrack.id || + video.state.selectedSubtitlesTrackId !== null) { + video.setExtraSubtitlesTrack(extraTrack.id); + } + + if (savedExternalTrack) { + defaultTrackSelected.current = true; + } + } + }, [ + player.streamState, + settings.subtitlesLanguage, + video.state.extraSubtitlesTracks, + video.state.selectedExtraSubtitlesTrackId, + video.state.selectedSubtitlesTrackId, + video.state.subtitlesTracks, + ]); + + useEffect(() => { + if (video.state.stream === null) { + return; + } + + const delay = player.streamState?.subtitleDelay; + if (typeof delay === 'number') { + video.setSubtitlesDelay(delay); + } + + const size = player.streamState?.subtitleSize; + if (typeof size === 'number') { + video.setSubtitlesSize(size); + } + + const offset = player.streamState?.subtitleOffset; + if (typeof offset === 'number') { + video.setSubtitlesOffset(offset); + } + }, [player.streamState, video.state.stream]); + + useEffect(() => { + defaultTrackSelected.current = false; + lastSelectedTrack.current = null; + }, [video.state.stream]); + + useEffect(() => { + if (!hasTracks) { + closeSubtitlesMenu(); + } + }, [closeSubtitlesMenu, hasTracks]); + + useEffect(() => { + const onSubtitlesTrackLoaded = () => { + toast.show({ + type: 'success', + title: t('PLAYER_SUBTITLES_LOADED'), + message: t('PLAYER_SUBTITLES_LOADED_EMBEDDED'), + timeout: 3000, + }); + }; + + const onExtraSubtitlesTrackLoaded = (track: SubtitleTrack) => { + 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 = (track: SubtitleTrack) => { + if (track.local) { + videoRef.current.setExtraSubtitlesTrack(track.id); + } + }; + + video.events.on('subtitlesTrackLoaded', onSubtitlesTrackLoaded); + video.events.on('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded); + video.events.on('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded); + video.events.on('implementationChanged', applySubtitleStyle); + + return () => { + video.events.off('subtitlesTrackLoaded', onSubtitlesTrackLoaded); + video.events.off('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded); + video.events.off('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded); + video.events.off('implementationChanged', applySubtitleStyle); + }; + }, [applySubtitleStyle, t, toast, video.events]); + + onShortcut('subtitlesDelay', (combo) => { + combo === 1 ? increaseDelay() : decreaseDelay(); + }, [increaseDelay, decreaseDelay], !menusOpen); + + onShortcut('subtitlesSize', (combo) => { + combo === 1 ? updateSize(1) : updateSize(-1); + }, [updateSize], !menusOpen); + + onShortcut('toggleSubtitles', () => { + const subtitlesEnabled = video.state.selectedSubtitlesTrackId !== null || + video.state.selectedExtraSubtitlesTrackId !== null; + + if (subtitlesEnabled) { + if (video.state.selectedSubtitlesTrackId) { + lastSelectedTrack.current = { + id: video.state.selectedSubtitlesTrackId, + embedded: true, + }; + } else if (video.state.selectedExtraSubtitlesTrackId) { + lastSelectedTrack.current = { + id: video.state.selectedExtraSubtitlesTrackId, + embedded: false, + }; + } + + video.setSubtitlesTrack(null); + video.setExtraSubtitlesTrack(null); + return; + } + + const savedTrack = player.streamState?.subtitleTrack ?? lastSelectedTrack.current; + if (savedTrack?.id) { + savedTrack.embedded ? + video.setSubtitlesTrack(savedTrack.id) + : + video.setExtraSubtitlesTrack(savedTrack.id); + } + }, [ + player.streamState, + video.state.selectedExtraSubtitlesTrackId, + video.state.selectedSubtitlesTrackId, + ], !menusOpen); + + onShortcut('subtitlesMenu', () => { + closeMenus(); + if (hasTracks) { + toggleSubtitlesMenu(); + } + }, [closeMenus, hasTracks, toggleSubtitlesMenu]); + + const menuProps = useMemo(() => ({ + subtitlesLanguage: settings.subtitlesLanguage, + interfaceLanguage: settings.interfaceLanguage, + subtitlesTracks: video.state.subtitlesTracks, + selectedSubtitlesTrackId: video.state.selectedSubtitlesTrackId, + subtitlesOffset: video.state.subtitlesOffset, + subtitlesSize: video.state.subtitlesSize, + extraSubtitlesTracks: video.state.extraSubtitlesTracks, + selectedExtraSubtitlesTrackId: video.state.selectedExtraSubtitlesTrackId, + extraSubtitlesOffset: video.state.extraSubtitlesOffset, + extraSubtitlesDelay: video.state.extraSubtitlesDelay, + extraSubtitlesSize: video.state.extraSubtitlesSize, + onSubtitlesTrackSelected: selectEmbeddedTrack, + onExtraSubtitlesTrackSelected: selectExtraTrack, + onSubtitlesOffsetChanged: changeOffset, + onSubtitlesSizeChanged: changeSize, + onExtraSubtitlesOffsetChanged: changeOffset, + onExtraSubtitlesDelayChanged: changeDelay, + onExtraSubtitlesSizeChanged: changeSize, + }), [ + changeDelay, + changeOffset, + changeSize, + selectEmbeddedTrack, + selectExtraTrack, + settings.interfaceLanguage, + settings.subtitlesLanguage, + video.state.extraSubtitlesDelay, + video.state.extraSubtitlesOffset, + video.state.extraSubtitlesSize, + video.state.extraSubtitlesTracks, + video.state.selectedExtraSubtitlesTrackId, + video.state.selectedSubtitlesTrackId, + video.state.subtitlesOffset, + video.state.subtitlesSize, + video.state.subtitlesTracks, + ]); + + return { + streamSubtitles, + allSubtitleTracks: allTracks, + extraSubtitleTracks: video.state.extraSubtitlesTracks, + selectedExtraSubtitleTrackId: video.state.selectedExtraSubtitlesTrackId, + subtitlesMenuProps: menuProps, + }; +}; + +export default useSubtitles; diff --git a/src/types/models/Player.d.ts b/src/types/models/Player.d.ts index 14e78a768..c83f54b34 100644 --- a/src/types/models/Player.d.ts +++ b/src/types/models/Player.d.ts @@ -15,13 +15,16 @@ type MetaItemPlayer = MetaItemPreview & { type SelectedStream = Stream & { deepLinks: StreamDeepLinks, + subtitles?: Subtitle[], }; type Subtitle = { id: string, lang: string, - origin: string, - url: string, + origin?: string, + url?: string | null, + fallbackUrl?: string | null, + label?: string | null, }; type SeriesInfo = { @@ -32,6 +35,7 @@ type SeriesInfo = { type SubtitlesTrackState = { id: string, embedded: boolean, + lang?: string, }; type AudioTrackState = { @@ -39,7 +43,7 @@ type AudioTrackState = { }; type StreamState = { - subtitleTrack?: SubtitlesTrackState, + subtitleTrack?: SubtitlesTrackState | null, subtitleDelay?: number, subtitleSize?: number, subtitleOffset?: number,