diff --git a/src/routes/MetaDetails/styles.less b/src/routes/MetaDetails/styles.less
index 82740275b..3d31369c7 100644
--- a/src/routes/MetaDetails/styles.less
+++ b/src/routes/MetaDetails/styles.less
@@ -32,7 +32,7 @@
width: 100%;
height: 100%;
object-fit: cover;
- object-position: top left;
+ object-position: right;
opacity: 0.3;
}
}
@@ -137,9 +137,16 @@
@media only screen and (max-width: @minimum) {
.metadetails-container {
+ .background-image-layer {
+ .background-image {
+ object-position: center;
+ }
+ }
+
.metadetails-content {
display: block;
overflow-y: auto;
+ padding-top: calc(var(--top-overlay-size) + var(--safe-area-inset-top));
.spacing {
display: none;
@@ -154,4 +161,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/routes/Player/ControlBar/ControlBar.js b/src/routes/Player/ControlBar/ControlBar.js
index e00f66d00..c9ba5cd7e 100644
--- a/src/routes/Player/ControlBar/ControlBar.js
+++ b/src/routes/Player/ControlBar/ControlBar.js
@@ -39,6 +39,9 @@ const ControlBar = React.forwardRef(({
onToggleSpeedMenu,
onToggleSideDrawer,
onToggleOptionsMenu,
+ videoScale,
+ videoScaleLabel,
+ onVideoScaleChanged,
onToggleStatisticsMenu,
onTouchEnd,
...props
@@ -176,6 +179,9 @@ const ControlBar = React.forwardRef(({
:
null
}
+
@@ -194,6 +200,9 @@ ControlBar.propTypes = {
volume: PropTypes.number,
muted: PropTypes.bool,
playbackSpeed: PropTypes.number,
+ videoScale: PropTypes.string,
+ videoScaleLabel: PropTypes.string,
+ onVideoScaleChanged: PropTypes.func,
subtitlesTracks: PropTypes.array,
audioTracks: PropTypes.array,
metaItem: PropTypes.object,
diff --git a/src/routes/Player/ControlBar/SeekBar/styles.less b/src/routes/Player/ControlBar/SeekBar/styles.less
index 54e75117d..2427b8b62 100644
--- a/src/routes/Player/ControlBar/SeekBar/styles.less
+++ b/src/routes/Player/ControlBar/SeekBar/styles.less
@@ -14,12 +14,15 @@
.label {
flex: none;
- width: 6rem;
+ width: 5.5rem;
white-space: nowrap;
text-overflow: ellipsis;
direction: rtl;
text-align: center;
+ font-size: 1.1rem;
+ font-variant-numeric: tabular-nums;
color: var(--primary-foreground-color);
+ opacity: 0.9;
}
.slider {
@@ -33,7 +36,8 @@
.slider-thumb {
background-color: var(--primary-accent-color);
-
+ transition: transform 150ms ease;
+
&:after {
content: "";
position: absolute;
@@ -46,5 +50,9 @@
filter: brightness(130%);
}
}
+
+ &:hover .slider-thumb {
+ transform: translateX(-50%) scale(1.2);
+ }
}
}
\ No newline at end of file
diff --git a/src/routes/Player/ControlBar/VolumeSlider/styles.less b/src/routes/Player/ControlBar/VolumeSlider/styles.less
index 517dfc0e5..74553f64d 100644
--- a/src/routes/Player/ControlBar/VolumeSlider/styles.less
+++ b/src/routes/Player/ControlBar/VolumeSlider/styles.less
@@ -5,20 +5,42 @@
:import('~stremio/components/Slider/styles.less') {
slider-track: track;
slider-track-after: track-after;
+ slider-thumb: thumb;
}
.volume-slider:not(:global(.disabled)) {
.slider-track {
background-color: var(--overlay-color);
+ opacity: 1;
}
.slider-track-after {
background-color: var(--primary-foreground-color);
}
+ .slider-thumb {
+ background-color: @color-secondaryvariant1-light4;
+ transition: transform 150ms ease;
+
+ &:after {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ border-radius: 100%;
+ box-shadow: 0 0 0 0.25rem white inset;
+ }
+ }
+
&:hover, &:global(.active) {
.slider-track-after {
background-color: var(--primary-foreground-color);
}
+
+ .slider-thumb {
+ transform: translateX(-50%) scale(1.2);
+ }
}
}
\ No newline at end of file
diff --git a/src/routes/Player/ControlBar/styles.less b/src/routes/Player/ControlBar/styles.less
index bcfbbdaef..062b97907 100644
--- a/src/routes/Player/ControlBar/styles.less
+++ b/src/routes/Player/ControlBar/styles.less
@@ -4,11 +4,11 @@
@import (reference) '~stremio/common/screen-sizes.less';
.control-bar-container {
- padding: 0 1.5rem;
+ padding: 0 2rem;
.seek-bar {
- --track-size: 0.5rem;
- --thumb-size: 1.3rem;
+ --track-size: 0.4rem;
+ --thumb-size: 1.2rem;
height: 2.5rem;
}
@@ -17,26 +17,34 @@
display: flex;
flex-direction: row;
align-items: center;
+ gap: 0.25rem;
.control-bar-button {
flex: none;
width: 4rem;
- height: 5rem;
+ height: 4rem;
display: flex;
justify-content: center;
align-items: center;
+ border-radius: 0.75rem;
+ transition: background-color 150ms ease;
+
+ &:hover:not(:global(.disabled)) {
+ background-color: var(--overlay-color);
+ }
&:global(.disabled) {
.icon {
- opacity: 0.5;
+ opacity: 0.4;
}
}
.icon {
flex: none;
- width: 2.5rem;
- height: 2.5rem;
+ width: 2.2rem;
+ height: 2.2rem;
color: var(--primary-foreground-color);
+ transition: transform 100ms ease;
}
}
@@ -46,7 +54,7 @@
flex: 0 1 10rem;
height: 4rem;
- margin: 0 1rem;
+ margin: 0 0.5rem;
}
.spacing {
@@ -60,11 +68,17 @@
display: none;
justify-content: center;
align-items: center;
+ border-radius: 0.75rem;
+ transition: background-color 150ms ease;
+
+ &:hover {
+ background-color: var(--overlay-color);
+ }
.icon {
flex: none;
- width: 2.5rem;
- height: 2.5rem;
+ width: 2.2rem;
+ height: 2.2rem;
color: var(--primary-foreground-color);
}
}
@@ -73,6 +87,7 @@
flex: none;
display: flex;
flex-direction: row;
+ gap: 0.25rem;
}
}
}
@@ -89,6 +104,7 @@
position: relative;
padding: 0 0.5rem;
overflow: visible;
+ gap: 0.15rem;
.volume-slider {
display: none;
@@ -104,6 +120,7 @@
bottom: 4.5rem;
padding: 0.5rem;
margin: 0.5rem;
+ gap: 0.15rem;
max-width: calc(100dvw - 1rem);
border-radius: var(--border-radius);
background-color: var(--modal-background-color);
diff --git a/src/routes/Player/Indicator/Indicator.tsx b/src/routes/Player/Indicator/Indicator.tsx
index 7525fe1cd..c7591b028 100644
--- a/src/routes/Player/Indicator/Indicator.tsx
+++ b/src/routes/Player/Indicator/Indicator.tsx
@@ -7,7 +7,13 @@ import styles from './Indicator.less';
type Property = {
label: string,
- format: (value: number) => string,
+ format: (value: number | string) => string,
+};
+
+const VIDEO_SCALE_KEYS: Record
= {
+ 'contain': 'PLAYER_SCALE_FIT',
+ 'cover': 'PLAYER_SCALE_CROP',
+ 'fill': 'PLAYER_SCALE_STRETCH',
};
const PROPERTIES: Record = {
@@ -15,9 +21,13 @@ const PROPERTIES: Record = {
label: 'SUBTITLES_DELAY',
format: (value) => `${(value / 1000).toFixed(2)}s`,
},
+ 'videoScale': {
+ label: 'VIDEO_SCALE',
+ format: (value) => t(VIDEO_SCALE_KEYS[String(value)] || String(value)),
+ },
};
-type VideoState = Record;
+type VideoState = Record;
type Props = {
className: string,
@@ -28,6 +38,7 @@ type Props = {
const Indicator = ({ className, videoState, disabled }: Props) => {
const timeout = useRef(null);
const prevVideoState = useRef(videoState);
+ const initialized = useRef>(new Set());
const [shown, show, hide] = useBinaryState(false);
const [current, setCurrent] = useState(null);
@@ -49,11 +60,15 @@ const Indicator = ({ className, videoState, disabled }: Props) => {
const next = videoState[property];
if (next && next !== prev) {
- setCurrent(property);
- show();
+ if (!initialized.current.has(property)) {
+ initialized.current.add(property);
+ } else {
+ setCurrent(property);
+ show();
- timeout.current && clearTimeout(timeout.current);
- timeout.current = setTimeout(hide, 1000);
+ timeout.current && clearTimeout(timeout.current);
+ timeout.current = setTimeout(hide, 1000);
+ }
}
}
diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js
index 859c11c67..ccc59a7d6 100644
--- a/src/routes/Player/Player.js
+++ b/src/routes/Player/Player.js
@@ -7,8 +7,9 @@ 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, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell, usePlatform, onShortcut } = require('stremio/common');
+const { useServices, useGamepad } = require('stremio/services');
+const { useContentGamepadNavigation } = require('stremio/services/GamepadNavigation');
+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');
@@ -25,17 +26,22 @@ 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');
+const { default: useMediaSession } = require('./useMediaSession');
const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang);
const findTrackById = (tracks, id) => tracks.find((track) => track.id === id);
+const GAMEPAD_HANDLER_ID = 'player';
+
const Player = ({ urlParams, queryParams }) => {
const { t } = useTranslation();
const services = useServices();
const shell = useShell();
+ const gamepad = useGamepad();
const forceTranscoding = React.useMemo(() => {
return queryParams.has('forceTranscoding');
}, [queryParams]);
@@ -56,6 +62,7 @@ const Player = ({ urlParams, queryParams }) => {
});
const playbackDevices = React.useMemo(() => streamingServer.playbackDevices !== null && streamingServer.playbackDevices.type === 'Ready' ? streamingServer.playbackDevices.content : [], [streamingServer]);
+ const playerRef = React.useRef(null);
const bufferingRef = React.useRef();
const errorRef = React.useRef();
@@ -84,33 +91,43 @@ 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 subtitlesEnabled = React.useRef(true);
const defaultAudioTrackSelected = React.useRef(false);
const playingOnExternalDevice = React.useRef(false);
const [error, setError] = React.useState(null);
const isNavigating = React.useRef(false);
+ const VIDEO_SCALES = ['contain', 'cover', 'fill'];
+ const VIDEO_SCALE_LABELS = { contain: t('PLAYER_SCALE_FIT'), cover: t('PLAYER_SCALE_CROP'), fill: t('PLAYER_SCALE_STRETCH') };
+
const playbackSpeed = React.useRef(video.state.playbackSpeed || 1);
const pressTimer = React.useRef(null);
const longPress = React.useRef(false);
const controlBarRef = React.useRef(null);
- 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 HOLD_DELAY = 400;
const handleNextVideoNavigation = React.useCallback((deepLinks, bingeWatching, ended) => {
if (ended) {
@@ -169,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);
@@ -235,19 +225,12 @@ const Player = ({ urlParams, queryParams }) => {
}, []);
- const onSubtitlesTrackSelected = React.useCallback((track) => {
- video.setSubtitlesTrack(track?.id ?? null);
- streamStateChanged({
- subtitleTrack: track ? { id: track.id, embedded: true, lang: track.lang } : null,
- });
- }, [streamStateChanged]);
-
- const onExtraSubtitlesTrackSelected = React.useCallback((track) => {
- video.setExtraSubtitlesTrack(track?.id ?? null);
- streamStateChanged({
- subtitleTrack: track ? { id: track.id, embedded: false, lang: track.lang } : null,
- });
- }, [streamStateChanged]);
+ const onVideoScaleChanged = React.useCallback(() => {
+ const currentScale = video.state.videoScale || 'contain';
+ const currentIndex = VIDEO_SCALES.indexOf(currentScale);
+ const nextScale = VIDEO_SCALES[(currentIndex + 1) % VIDEO_SCALES.length];
+ video.setVideoScale(nextScale);
+ }, [video.state.videoScale]);
const onAudioTrackSelected = React.useCallback((id) => {
video.setAudioTrack(id);
@@ -258,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;
@@ -357,9 +309,78 @@ 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) {
+ onPlayRequested();
+ setSeeking(false);
+ } else {
+ onPauseRequested();
+ }
+ }
+ }, [menusOpen, nextVideoPopupOpen, video.state.paused]);
+
+ const onSeekPrev = React.useCallback((event) => {
+ if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
+ const seekDuration = event?.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration;
+ const seekTime = video.state.time - seekDuration;
+ setSeeking(true);
+ onSeekRequested(Math.max(seekTime, 0));
+ }
+ }, [menusOpen, nextVideoPopupOpen, video.state.time]);
+
+ const onSeekNext = React.useCallback((event) => {
+ if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
+ const seekDuration = event?.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration;
+ setSeeking(true);
+ onSeekRequested(video.state.time + seekDuration);
+ }
+ }, [menusOpen, nextVideoPopupOpen, video.state.time]);
+
+ const onVolumeUp = React.useCallback(() => {
+ if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
+ onVolumeChangeRequested(Math.min(video.state.volume + 5, 200));
+ }
+ }, [menusOpen, nextVideoPopupOpen, video.state.volume]);
+
+ const onVolumeDown = React.useCallback(() => {
+ if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
+ onVolumeChangeRequested(Math.max(video.state.volume - 5, 0));
+ }
+ }, [menusOpen, nextVideoPopupOpen, video.state.volume]);
+
+ const onGamepadSeekAndVol = React.useCallback((axis) => {
+ switch(axis) {
+ case 'left': {
+ onSeekPrev();
+ break;
+ }
+ case 'right': {
+ onSeekNext();
+ break;
+ }
+ case 'up': {
+ onVolumeUp();
+ break;
+ }
+ case 'down': {
+ onVolumeDown();
+ break;
+ }
+ }
+ }, [onSeekPrev, onSeekNext, onVolumeUp, onVolumeDown]);
+
+ useContentGamepadNavigation(playerRef, GAMEPAD_HANDLER_ID);
+
+ React.useEffect(() => {
+ gamepad?.on('buttonX', GAMEPAD_HANDLER_ID, onPlayPause);
+ gamepad?.on('analogRight', GAMEPAD_HANDLER_ID, onGamepadSeekAndVol);
+
+ return () => {
+ gamepad?.off('buttonX', GAMEPAD_HANDLER_ID);
+ gamepad?.off('analogRight', GAMEPAD_HANDLER_ID);
+ };
+ }, [onPlayPause, onGamepadSeekAndVol]);
React.useEffect(() => {
setError(null);
@@ -369,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 &&
@@ -404,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);
@@ -449,39 +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 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) {
- video.setSubtitlesTrack(subtitlesTrack.id);
- defaultSubtitlesSelected.current = true;
- } else if (extraSubtitlesTrack && extraSubtitlesTrack.id) {
- video.setExtraSubtitlesTrack(extraSubtitlesTrack.id);
- defaultSubtitlesSelected.current = true;
- }
- }
- }, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks, player.streamState]);
-
// Auto audio track selection
React.useEffect(() => {
if (!defaultAudioTrackSelected.current) {
@@ -497,28 +470,7 @@ 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;
nextVideoPopupDismissed.current = false;
playingOnExternalDevice.current = false;
@@ -527,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();
@@ -589,63 +534,30 @@ const Player = ({ urlParams, queryParams }) => {
}
}, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]);
- // Media Session PlaybackState
+ useMediaSession(video.state, player, onPlayRequested, onPauseRequested, onNextVideoRequested);
+
React.useEffect(() => {
- if (!navigator.mediaSession) return;
-
- const playbackState = !video.state.paused ? 'playing' : 'paused';
- navigator.mediaSession.playbackState = playbackState;
-
- return () => navigator.mediaSession.playbackState = 'none';
- }, [video.state.paused]);
-
- // Media Session Metadata
- React.useEffect(() => {
- if (!navigator.mediaSession) return;
-
- const metaItem = player.metaItem && player.metaItem?.type === 'Ready' ? player.metaItem.content : null;
- const videoId = player.selected ? player.selected?.streamRequest?.path?.id : null;
- const video = metaItem ? metaItem.videos.find(({ id }) => id === videoId) : null;
-
- const videoInfo = video && video.season && video.episode ? ` (${video.season}x${video.episode})` : null;
- const videoTitle = video ? `${video.title}${videoInfo}` : null;
- const metaTitle = metaItem ? metaItem.name : null;
- const imageUrl = metaItem ? metaItem.logo : null;
-
- const title = videoTitle ?? metaTitle;
- const artist = videoTitle ? metaTitle : undefined;
- const artwork = imageUrl ? [{ src: imageUrl }] : undefined;
-
- if (title) {
- navigator.mediaSession.metadata = new MediaMetadata({
- title,
- artist,
- artwork,
- });
- }
- }, [player.metaItem, player.selected]);
-
- // Media Session Actions
- React.useEffect(() => {
- if (!navigator.mediaSession) return;
-
- navigator.mediaSession.setActionHandler('play', onPlayRequested);
- navigator.mediaSession.setActionHandler('pause', onPauseRequested);
-
- const nexVideoCallback = player.nextVideo ? onNextVideoRequested : null;
- navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback);
- }, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]);
-
- onShortcut('playPause', () => {
- if (video.state.paused !== null) {
- if (video.state.paused) {
- onPlayRequested();
- setSeeking(false);
- } else if (!pressTimer.current) {
- onPauseRequested();
+ const onMediaKey = (action) => {
+ switch (action) {
+ case 'play-pause':
+ video.state.paused ? onPlayRequested() : onPauseRequested();
+ break;
+ case 'next-track':
+ if (player.nextVideo !== null) {
+ video.setTime(0);
+ onNextVideoRequested();
+ }
+ break;
+ case 'previous-track':
+ if (video.state.time !== null && video.state.time > 5000) {
+ onSeekRequested(0);
+ }
+ break;
}
- }
- }, [video.state.paused, pressTimer.current, onPlayRequested, onPauseRequested], !menusOpen);
+ };
+ shell.on('media-key', onMediaKey);
+ return () => shell.off('media-key', onMediaKey);
+ }, [video.state.paused, video.state.time, player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested, onSeekRequested]);
onShortcut('seekForward', (combo) => {
if (video.state.time !== null) {
@@ -675,38 +587,10 @@ const Player = ({ urlParams, queryParams }) => {
onShortcut('volumeDown', () => {
if (video.state.volume !== null) {
- onVolumeChangeRequested(Math.min(video.state.volume - 5, 200));
+ onVolumeChangeRequested(Math.max(video.state.volume - 5, 0));
}
}, [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 savedTrack = player.streamState?.subtitleTrack;
-
- if (subtitlesEnabled.current) {
- video.setSubtitlesTrack(null);
- video.setExtraSubtitlesTrack(null);
- } else if (savedTrack?.id) {
- savedTrack.embedded ? video.setSubtitlesTrack(savedTrack.id) : video.setExtraSubtitlesTrack(savedTrack.id);
- }
-
- subtitlesEnabled.current = !subtitlesEnabled.current;
- }, [player.streamState], !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) {
@@ -743,7 +627,7 @@ const Player = ({ urlParams, queryParams }) => {
onShortcut('statisticsMenu', () => {
closeMenus();
const stream = player.selected?.stream;
- if (streamingServer?.statistics?.type !== 'Err' && typeof stream === 'string' && typeof stream === 'number') {
+ if (streamingServer?.statistics?.type !== 'Err' && typeof stream?.infoHash === 'string' && typeof stream?.fileIdx === 'number') {
toggleStatisticsMenu();
}
}, [player.selected, streamingServer.statistics, toggleStatisticsMenu]);
@@ -791,7 +675,17 @@ const Player = ({ urlParams, queryParams }) => {
if (e.code === 'Space') {
clearTimeout(pressTimer.current);
pressTimer.current = null;
- onPlaybackSpeedChanged(playbackSpeed.current);
+ if (longPress.current) {
+ onPlaybackSpeedChanged(playbackSpeed.current);
+ } else if (!menusOpen && video.state.paused !== null) {
+ if (video.state.paused) {
+ onPlayRequested();
+ setSeeking(false);
+ } else {
+ onPauseRequested();
+ }
+ }
+ longPress.current = false;
}
};
@@ -830,12 +724,23 @@ const Player = ({ urlParams, queryParams }) => {
}
};
+ const onBlur = () => {
+ clearTimeout(pressTimer.current);
+ pressTimer.current = null;
+ if (longPress.current) {
+ onPlaybackSpeedChanged(playbackSpeed.current);
+ longPress.current = false;
+ }
+ setSeeking(false);
+ };
+
if (routeFocused) {
window.addEventListener('keyup', onKeyUp);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('wheel', onWheel);
window.addEventListener('mousedown', onMouseDownHold);
window.addEventListener('mouseup', onMouseUp);
+ window.addEventListener('blur', onBlur);
}
return () => {
window.removeEventListener('keyup', onKeyUp);
@@ -843,24 +748,17 @@ const Player = ({ urlParams, queryParams }) => {
window.removeEventListener('wheel', onWheel);
window.removeEventListener('mousedown', onMouseDownHold);
window.removeEventListener('mouseup', onMouseUp);
+ window.removeEventListener('blur', onBlur);
};
- }, [routeFocused, menusOpen, video.state.volume]);
+ }, [routeFocused, menusOpen, video.state.volume, video.state.paused]);
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);
};
}, []);
@@ -873,7 +771,7 @@ const Player = ({ urlParams, queryParams }) => {
}, []);
return (
- {
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}
/>
{
title={player.title !== null ? player.title : ''}
backButton={true}
fullscreenButton={true}
+ hdrInfo={video.state.hdrInfo}
onMouseMove={onBarMouseMove}
onMouseOver={onBarMouseMove}
/>
@@ -964,7 +863,7 @@ const Player = ({ urlParams, queryParams }) => {
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}
@@ -981,6 +880,9 @@ const Player = ({ urlParams, queryParams }) => {
onToggleSubtitlesMenu={toggleSubtitlesMenu}
onToggleAudioMenu={toggleAudioMenu}
onToggleSpeedMenu={toggleSpeedMenu}
+ videoScale={video.state.videoScale}
+ videoScaleLabel={VIDEO_SCALE_LABELS[video.state.videoScale || 'contain']}
+ onVideoScaleChanged={onVideoScaleChanged}
onToggleStatisticsMenu={toggleStatisticsMenu}
onToggleSideDrawer={toggleSideDrawer}
onMouseMove={onBarMouseMove}
@@ -1022,24 +924,7 @@ const Player = ({ urlParams, queryParams }) => {
@@ -1062,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/SideDrawer/SideDrawer.less b/src/routes/Player/SideDrawer/SideDrawer.less
index e6831a71d..31168db22 100644
--- a/src/routes/Player/SideDrawer/SideDrawer.less
+++ b/src/routes/Player/SideDrawer/SideDrawer.less
@@ -26,6 +26,17 @@
transition: transform 0.3s ease-in-out;
z-index: 1;
+ // Safari has a compositing bug where transform animations on a parent with
+ // scrollable children causes the video player element to shift left during the animation.
+ // Disable the slide animation on Safari until WebKit resolves this.
+ @supports (hanging-punctuation: first) and (-webkit-appearance: none) {
+ &:global(.slide-left-enter),
+ &:global(.slide-left-active),
+ &:global(.slide-left-exit) {
+ transition: none;
+ }
+ }
+
.close-button {
display: none;
position: absolute;
diff --git a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less
new file mode 100644
index 000000000..600b79658
--- /dev/null
+++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less
@@ -0,0 +1,82 @@
+// Copyright (C) 2017-2026 Smart code 203358507
+
+@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
+
+.variant-option {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ height: 4rem;
+ padding: 0 1.5rem;
+ margin-bottom: 0.5rem;
+ border-radius: var(--border-radius);
+
+ &:global(.selected), &:hover {
+ background-color: var(--overlay-color);
+ }
+
+ .info {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+
+ .variant-label {
+ flex: 1;
+ font-size: 1.1rem;
+ line-height: 1.5rem;
+ color: var(--primary-foreground-color);
+ text-wrap: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .variant-origin {
+ font-size: 0.9rem;
+ color: var(--color-placeholder-text);
+ text-wrap: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+
+ .icon {
+ flex: none;
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 100%;
+ margin-left: 1rem;
+ background-color: var(--secondary-accent-color);
+ }
+}
+
+.context-menu-option {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 1rem;
+ min-width: 16rem;
+ padding: 1.25rem 1.5rem;
+
+ &:hover, &:focus {
+ background-color: var(--overlay-color);
+ }
+
+ .menu-icon {
+ flex: none;
+ width: 1.4rem;
+ height: 1.4rem;
+ color: var(--color-placeholder);
+ }
+
+ .context-menu-option-label {
+ flex: 1;
+ min-width: 0;
+ font-size: 1rem;
+ font-weight: 400;
+ color: var(--primary-foreground-color);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+}
diff --git a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx
new file mode 100644
index 000000000..9bd8f909c
--- /dev/null
+++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx
@@ -0,0 +1,136 @@
+// Copyright (C) 2017-2026 Smart code 203358507
+
+import React, { useCallback, useMemo, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, ContextMenu } from 'stremio/components';
+import { languages, useToast } from 'stremio/common';
+import classNames from 'classnames';
+import Icon from '@stremio/stremio-icons/react';
+import styles from './SubtitleVariant.less';
+
+type SubtitlesTrack = {
+ id: string,
+ addonSubtitleId?: string,
+ lang: string,
+ origin: string,
+ label?: string,
+ url?: string,
+ fallbackUrl?: string,
+ embedded?: boolean,
+ local?: boolean,
+ exclusive?: boolean,
+};
+
+type Props = {
+ track: SubtitlesTrack,
+ selected: boolean,
+ onSelect: (track: SubtitlesTrack) => void,
+};
+
+const hasValidLabel = (label?: string) => label && label.length > 0 && !label.startsWith('http');
+
+const SubtitleVariant = ({ track, selected, onSelect }: Props) => {
+ const { t } = useTranslation();
+ const toast = useToast();
+ const buttonRef = useRef(null);
+ const triggers = useMemo(() => [buttonRef], []);
+
+ const downloadUrl = track.fallbackUrl || track.url;
+ const variantLabel = hasValidLabel(track.label) ? track.label : languages.label(track.lang);
+ const downloadFileName = hasValidLabel(track.label) ? track.label : `subtitle-${track.lang || 'unknown'}`;
+ const canCopyUrl = typeof downloadUrl === 'string' && !downloadUrl.startsWith('blob:');
+ const hoverTitle = hasValidLabel(track.label)
+ ? track.label
+ : downloadUrl?.split('/').pop()?.split('?')[0] || variantLabel;
+
+ const onSelectClick = useCallback(() => {
+ onSelect(track);
+ }, [onSelect, track]);
+
+ const copyToClipboard = useCallback((value: string, successKey: string, errorKey: string) => {
+ navigator.clipboard.writeText(value)
+ .then(() => toast.show({ type: 'success', title: t(successKey), timeout: 4000 }))
+ .catch(() => toast.show({ type: 'error', title: t(errorKey), timeout: 4000 }));
+ }, [toast, t]);
+
+ const onCopyUrlClick = useCallback(() => {
+ if (downloadUrl) {
+ copyToClipboard(downloadUrl, 'PLAYER_COPY_SUBTITLE_URL_SUCCESS', 'PLAYER_COPY_SUBTITLE_URL_ERROR');
+ }
+ }, [downloadUrl, copyToClipboard]);
+
+ const onCopyIdClick = useCallback(() => {
+ if (track.addonSubtitleId) {
+ copyToClipboard(track.addonSubtitleId, 'PLAYER_COPY_SUBTITLE_ID_SUCCESS', 'PLAYER_COPY_SUBTITLE_ID_ERROR');
+ }
+ }, [track.addonSubtitleId, copyToClipboard]);
+
+ return (
+
+ );
+};
+
+export default SubtitleVariant;
diff --git a/src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts b/src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts
new file mode 100644
index 000000000..16bda596b
--- /dev/null
+++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts
@@ -0,0 +1,5 @@
+// Copyright (C) 2017-2026 Smart code 203358507
+
+import SubtitleVariant from './SubtitleVariant';
+
+export default SubtitleVariant;
diff --git a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js
index adbaf4a80..a71969bd5 100644
--- a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js
+++ b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js
@@ -9,6 +9,7 @@ const { Button } = require('stremio/components');
const styles = require('./styles');
const { t } = require('i18next');
const { default: Stepper } = require('./Stepper');
+const { default: SubtitleVariant } = require('./SubtitleVariant');
const ORIGIN_PRIORITIES = [
'LOCAL',
@@ -47,7 +48,7 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
const userLanguage = languages.toCode(props.subtitlesLanguage) ?? DEFAULT_SUBTITLES_LANGUAGE;
const interfaceLanguage = languages.toCode(props.interfaceLanguage) ?? DEFAULT_SUBTITLES_LANGUAGE;
const priorities = [LOCAL_SUBTITLES_LANGUAGE, userLanguage, interfaceLanguage];
- const langs = Object.keys(Object.groupBy(allSubtitles, ({ lang }) => lang)).sort((a, b) => a.localeCompare(b));
+ const langs = [...new Set(allSubtitles.map(({ lang }) => lang))].sort((a, b) => a.localeCompare(b));
return sortByValues(langs, priorities);
}, [allSubtitles, props.subtitlesLanguage, props.interfaceLanguage]);
@@ -102,9 +103,8 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
}
}
}, [allSubtitles, props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]);
- const subtitlesTrackOnClick = React.useCallback((event) => {
- const track = subtitlesTracksForLanguage.find((t) => t.id === event.currentTarget.dataset.id) ?? null;
- if (track?.embedded) {
+ const subtitlesTrackOnSelect = React.useCallback((track) => {
+ if (track.embedded) {
if (typeof props.onSubtitlesTrackSelected === 'function') {
props.onSubtitlesTrackSelected(track);
}
@@ -113,7 +113,7 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
props.onExtraSubtitlesTrackSelected(track);
}
}
- }, [subtitlesTracksForLanguage, props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]);
+ }, [props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]);
const onSubtitlesDelayChanged = React.useCallback((value) => {
if (typeof props.selectedExtraSubtitlesTrackId === 'string') {
if (props.extraSubtitlesDelay !== null && !isNaN(props.extraSubtitlesDelay)) {
@@ -190,24 +190,12 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
subtitlesTracksForLanguage.length > 0 ?
{subtitlesTracksForLanguage.map((track, index) => (
-
-
-
- {
- (track.label && track.label.length > 0 && !track.label.startsWith('http')) ? track.label : languages.label(track.lang)
- }
-
-
- { t(track.origin) }
-
-
- {
- props.selectedSubtitlesTrackId === track.id || props.selectedExtraSubtitlesTrackId === track.id ?
-
- :
- null
- }
-
+
))}
:
@@ -276,7 +264,11 @@ SubtitlesMenu.propTypes = {
id: PropTypes.string.isRequired,
lang: PropTypes.string.isRequired,
origin: PropTypes.string.isRequired,
- label: PropTypes.string.isRequired
+ label: PropTypes.string,
+ url: PropTypes.string,
+ embedded: PropTypes.bool,
+ local: PropTypes.bool,
+ exclusive: PropTypes.bool
})),
selectedExtraSubtitlesTrackId: PropTypes.string,
extraSubtitlesOffset: PropTypes.number,
diff --git a/src/routes/Player/SubtitlesMenu/styles.less b/src/routes/Player/SubtitlesMenu/styles.less
index bed7be75d..b0aa3b051 100644
--- a/src/routes/Player/SubtitlesMenu/styles.less
+++ b/src/routes/Player/SubtitlesMenu/styles.less
@@ -27,7 +27,7 @@
overflow-y: auto;
padding: 0 1rem;
- .language-option, .variant-option {
+ .language-option {
display: flex;
flex-direction: row;
align-items: center;
@@ -40,13 +40,10 @@
background-color: var(--overlay-color);
}
- .language-label, .variant-label {
+ .language-label {
flex: 1;
font-size: 1.1rem;
color: var(--primary-foreground-color);
- }
-
- .language-label, .variant-label, .variant-origin {
text-wrap: nowrap;
text-overflow: ellipsis;
}
@@ -60,26 +57,6 @@
background-color: var(--secondary-accent-color);
}
}
-
- .variant-option {
- height: 4rem;
-
- .info {
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
-
- .variant-label {
- line-height: 1.5rem;
- }
-
- .variant-origin {
- font-size: 0.9rem;
- color: var(--color-placeholder-text);
- }
- }
- }
}
}
diff --git a/src/routes/Player/styles.less b/src/routes/Player/styles.less
index 4894791f0..23b0b0c32 100644
--- a/src/routes/Player/styles.less
+++ b/src/routes/Player/styles.less
@@ -64,9 +64,11 @@ html:not(.active-slider-within) {
right: 0;
top: 0;
left: 0;
+ height: 8rem;
z-index: -1;
- box-shadow: 0 0 8rem 6rem @color-background-dark5;
content: "";
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.35) 0%, transparent 100%);
+ pointer-events: none;
}
.nav-bar-button-container {
@@ -95,15 +97,18 @@ html:not(.active-slider-within) {
&.control-bar-layer {
top: initial;
overflow: visible;
+ padding-bottom: 0.5rem;
&::before {
position: absolute;
right: 0;
bottom: 0;
left: 0;
+ height: 10rem;
z-index: -1;
- box-shadow: 0 0 8rem 8rem @color-background-dark5;
content: "";
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.35) 0%, transparent 100%);
+ pointer-events: none;
}
}
@@ -118,8 +123,8 @@ html:not(.active-slider-within) {
top: initial;
left: initial;
right: 4rem;
- bottom: 8rem;
- max-height: calc(100% - 13.5rem);
+ bottom: 7.5rem;
+ max-height: calc(100% - 13rem);
max-width: calc(100% - 4rem);
border-radius: var(--border-radius);
background-color: var(--modal-background-color);
diff --git a/src/routes/Player/useMediaSession.ts b/src/routes/Player/useMediaSession.ts
new file mode 100644
index 000000000..7a63423bd
--- /dev/null
+++ b/src/routes/Player/useMediaSession.ts
@@ -0,0 +1,57 @@
+import { useEffect } from 'react';
+
+const useMediaSession = (
+ videoState: VideoState,
+ player: Player,
+ onPlayRequested: () => void,
+ onPauseRequested: () => void,
+ onNextVideoRequested: () => void,
+) => {
+ useEffect(() => {
+ if (!navigator.mediaSession) return;
+
+ const playbackState = !videoState.paused ? 'playing' : 'paused';
+ navigator.mediaSession.playbackState = playbackState;
+
+ return () => {
+ navigator.mediaSession.playbackState = 'none';
+ };
+ }, [videoState.paused]);
+
+ useEffect(() => {
+ if (!navigator.mediaSession) return;
+
+ const metaItem = player.metaItem && player.metaItem?.type === 'Ready' ? player.metaItem.content as MetaItemPlayer : null;
+ const videoId = player.selected ? player.selected?.streamRequest?.path?.id : null;
+ const video = metaItem?.videos.find(({ id }) => id === videoId);
+
+ const videoInfo = video?.season && video?.episode ? ` (${video.season}x${video.episode})` : null;
+ const videoTitle = video ? `${video.title}${videoInfo}` : null;
+ const metaTitle = metaItem ? metaItem.name : null;
+ const imageUrl = metaItem ? metaItem.logo : null;
+
+ const title = videoTitle ?? metaTitle;
+ const artist = (videoTitle && metaTitle) ?? undefined;
+ const artwork = imageUrl ? [{ src: imageUrl }] : undefined;
+
+ if (title) {
+ navigator.mediaSession.metadata = new MediaMetadata({
+ title,
+ artist,
+ artwork,
+ });
+ }
+ }, [player.metaItem, player.selected]);
+
+ useEffect(() => {
+ if (!navigator.mediaSession) return;
+
+ navigator.mediaSession.setActionHandler('play', onPlayRequested);
+ navigator.mediaSession.setActionHandler('pause', onPauseRequested);
+
+ const nexVideoCallback = player.nextVideo ? onNextVideoRequested : null;
+ navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback);
+ }, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]);
+};
+
+export default useMediaSession;
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/routes/Player/useVideo.js b/src/routes/Player/useVideo.js
index b3a5d2e39..241a5af00 100644
--- a/src/routes/Player/useVideo.js
+++ b/src/routes/Player/useVideo.js
@@ -22,6 +22,7 @@ const useVideo = () => {
muted: null,
playbackSpeed: null,
videoParams: null,
+ hdrInfo: null,
audioTracks: [],
selectedAudioTrackId: null,
subtitlesTracks: [],
@@ -142,6 +143,10 @@ const useVideo = () => {
setProp('extraSubtitlesOffset', offset);
};
+ const setVideoScale = (scale) => {
+ setProp('videoScale', scale);
+ };
+
const setSubtitlesTextColor = (color) => {
setProp('subtitlesTextColor', color);
setProp('extraSubtitlesTextColor', color);
@@ -238,6 +243,7 @@ const useVideo = () => {
setSubtitlesBackgroundColor,
setSubtitlesOutlineColor,
setExtraSubtitlesTrack,
+ setVideoScale,
};
};
diff --git a/src/routes/Player/videoState.d.ts b/src/routes/Player/videoState.d.ts
new file mode 100644
index 000000000..0f8a78c10
--- /dev/null
+++ b/src/routes/Player/videoState.d.ts
@@ -0,0 +1,3 @@
+type VideoState = {
+ paused?: boolean;
+};
diff --git a/src/routes/Settings/Interface/Interface.tsx b/src/routes/Settings/Interface/Interface.tsx
index 038ec4304..9b8e20de7 100644
--- a/src/routes/Settings/Interface/Interface.tsx
+++ b/src/routes/Settings/Interface/Interface.tsx
@@ -17,6 +17,7 @@ const Interface = forwardRef(({ profile }: Props, ref) =>
quitOnCloseToggle,
escExitFullscreenToggle,
hideSpoilersToggle,
+ gamepadSupportToggle,
} = useInterfaceOptions(profile);
return (
@@ -57,6 +58,12 @@ const Interface = forwardRef(({ profile }: Props, ref) =>
{...hideSpoilersToggle}
/>
+
);
});
diff --git a/src/routes/Settings/Interface/useInterfaceOptions.ts b/src/routes/Settings/Interface/useInterfaceOptions.ts
index 4bff87423..24a4abd20 100644
--- a/src/routes/Settings/Interface/useInterfaceOptions.ts
+++ b/src/routes/Settings/Interface/useInterfaceOptions.ts
@@ -102,12 +102,29 @@ const useInterfaceOptions = (profile: Profile) => {
}
}), [profile.settings]);
+ const gamepadSupportToggle = useMemo(() => ({
+ checked: profile.settings.gamepadSupport,
+ onClick: () => {
+ core.transport.dispatch({
+ action: 'Ctx',
+ args: {
+ action: 'UpdateSettings',
+ args: {
+ ...profile.settings,
+ gamepadSupport: !profile.settings.gamepadSupport
+ }
+ }
+ });
+ }
+ }), [profile.settings]);
+
return {
interfaceLanguageSelect,
interfaceSize,
escExitFullscreenToggle,
quitOnCloseToggle,
hideSpoilersToggle,
+ gamepadSupportToggle,
};
};
diff --git a/src/services/GamepadContext/GamepadContext.ts b/src/services/GamepadContext/GamepadContext.ts
new file mode 100644
index 000000000..cf147581a
--- /dev/null
+++ b/src/services/GamepadContext/GamepadContext.ts
@@ -0,0 +1,15 @@
+// Copyright (C) 2017-2026 Smart code 203358507
+
+import { createContext } from 'react';
+
+export type ControllerType = 'playstation' | 'xbox' | 'generic';
+
+const GamepadContext = createContext<{
+ on: (event: string, id: string, callback: (data?: string) => void) => void;
+ off: (event: string, id: string) => void;
+ lock: (prefix: string) => void;
+ unlock: () => void;
+ controllerType: ControllerType;
+} | null>(null);
+
+export default GamepadContext;
diff --git a/src/services/GamepadContext/GamepadProvider.tsx b/src/services/GamepadContext/GamepadProvider.tsx
new file mode 100644
index 000000000..782d31b18
--- /dev/null
+++ b/src/services/GamepadContext/GamepadProvider.tsx
@@ -0,0 +1,271 @@
+// Copyright (C) 2017-2026 Smart code 203358507
+
+import React, { useEffect, useRef, useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import useToast from 'stremio/common/Toast/useToast';
+import GamepadContext from './GamepadContext';
+import type { ControllerType } from './GamepadContext';
+
+type GamepadEventHandlers = Map void>>;
+
+type GamepadProviderProps = {
+ enabled: boolean;
+ onGuide?: () => void;
+ children: React.ReactNode;
+};
+
+const detectControllerType = (gamepad: Gamepad): ControllerType => {
+ const id = gamepad.id.toLowerCase();
+ // Sony vendor id 054c — DualShock / DualSense / generic PlayStation
+ if (/sony|playstation|dualsense|dualshock|054c/.test(id)) return 'playstation';
+ // Microsoft vendor id 045e — Xbox / XInput
+ if (/xbox|microsoft|xinput|045e/.test(id)) return 'xbox';
+ // Browser "Standard Gamepad" mapping mirrors the Xbox layout
+ if (gamepad.mapping === 'standard') return 'xbox';
+ return 'generic';
+};
+
+const GamepadProvider = ({ enabled, onGuide, children }: GamepadProviderProps) => {
+ const { t } = useTranslation();
+ const toast = useToast();
+ const connectedGamepads = useRef(0);
+ const lastButtonState = useRef([]);
+ const lastButtonPressedTime = useRef(0);
+ const axisTimer = useRef(0);
+ const axisTimerRight = useRef(0);
+ const eventHandlers = useRef(new Map());
+ const lockPrefix = useRef(null);
+ const [controllerType, setControllerType] = useState('generic');
+
+ const on = useCallback((event: string, id: string, callback: (data?: string) => void) => {
+ if (!eventHandlers.current.has(event)) {
+ eventHandlers.current.set(event, new Map());
+ }
+
+ const handlers = eventHandlers.current.get(event)!;
+
+ // Ensure only one handler per component
+ handlers.set(id, callback);
+ }, []);
+
+ const off = useCallback((event: string, id: string) => {
+ const handlersMap = eventHandlers.current.get(event);
+ handlersMap?.delete(id);
+ if (handlersMap?.size === 0) {
+ eventHandlers.current.delete(event);
+ }
+ }, []);
+
+ const lock = useCallback((prefix: string) => {
+ lockPrefix.current = prefix;
+ }, []);
+
+ const unlock = useCallback(() => {
+ lockPrefix.current = null;
+ }, []);
+
+ const emit = (event: string, data?: string) => {
+ if (eventHandlers.current.has(event)) {
+ const handlersMap = eventHandlers.current.get(event)!;
+
+ if (!handlersMap || handlersMap.size === 0) return;
+
+ if (lockPrefix.current) {
+ const matching = Array.from(handlersMap.entries())
+ .filter(([id]) => id.startsWith(lockPrefix.current!));
+ if (matching.length > 0) {
+ matching[matching.length - 1][1](data);
+ }
+ return;
+ }
+
+ const latestHandler = Array.from(handlersMap.values()).slice(-1)[0];
+ if (latestHandler) {
+ latestHandler(data);
+ }
+ }
+ };
+
+ const onGamepadConnected = useCallback((e: GamepadEvent) => {
+ setControllerType(detectControllerType(e.gamepad));
+ // @ts-expect-error show() expects no arguments
+ toast.show({
+ type: 'info',
+ title: t('GAMEPAD_CONNECTED'),
+ timeout: 4000,
+ });
+ }, [toast, t]);
+
+ const onGamepadDisconnected = useCallback(() => {
+ const remaining = Array.from(navigator.getGamepads()).filter(
+ (gp) => gp !== null
+ ) as Gamepad[];
+ setControllerType(remaining.length > 0 ? detectControllerType(remaining[0]) : 'generic');
+ // @ts-expect-error show() expects no arguments
+ toast.show({
+ type: 'info',
+ title: t('GAMEPAD_DISCONNECTED'),
+ timeout: 4000,
+ });
+ }, [toast, t]);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ if (typeof navigator.getGamepads === 'function') {
+ const existing = Array.from(navigator.getGamepads()).filter(
+ (gp) => gp !== null
+ ) as Gamepad[];
+ if (existing.length > 0) {
+ setControllerType(detectControllerType(existing[0]));
+ }
+ }
+
+ window.addEventListener('gamepadconnected', onGamepadConnected);
+ window.addEventListener('gamepaddisconnected', onGamepadDisconnected);
+
+ return () => {
+ window.removeEventListener('gamepadconnected', onGamepadConnected);
+ window.removeEventListener('gamepaddisconnected', onGamepadDisconnected);
+ };
+ }, [enabled, onGamepadConnected, onGamepadDisconnected]);
+
+ useEffect(() => {
+ if (onGuide) {
+ on('buttonX', 'guide', onGuide);
+ }
+ return () => {
+ off('buttonX', 'guide');
+ };
+ }, [onGuide]);
+
+ useEffect(() => {
+ if (!enabled || typeof navigator.getGamepads !== 'function') return;
+
+ let animationFrameId: number;
+
+ const updateStatus = () => {
+ if (document.hasFocus()) {
+ const currentTime = Date.now();
+ const controllers = Array.from(navigator.getGamepads()).filter(
+ (gp) => gp !== null
+ ) as Gamepad[];
+
+ connectedGamepads.current = controllers.length;
+
+ controllers.forEach((controller, index) => {
+ const buttonsState = controller.buttons.reduce(
+ (buttons, button, i) => buttons | (button.pressed ? 1 << i : 0),
+ 0
+ );
+
+ const processButton =
+ currentTime - lastButtonPressedTime.current > 250;
+ if (
+ lastButtonState.current[index] !== buttonsState ||
+ processButton
+ ) {
+ lastButtonPressedTime.current = currentTime;
+ lastButtonState.current[index] = buttonsState;
+
+ if (buttonsState & (1 << 0)) emit('buttonA');
+ if (buttonsState & (1 << 1)) emit('buttonB');
+ if (buttonsState & (1 << 2)) emit('buttonX');
+ if (buttonsState & (1 << 3)) emit('buttonY');
+ if (buttonsState & (1 << 4)) emit('buttonLT');
+ if (buttonsState & (1 << 5)) emit('buttonRT');
+ }
+
+ const deadZone = 0.05;
+ const maxSpeed = 100;
+ let axisHandled = false;
+
+ if (controller.axes[0] < -deadZone) {
+ if (
+ currentTime - axisTimer.current >
+ maxSpeed + (2000 - Math.abs(controller.axes[0]) * 2000)
+ ) {
+ emit('analog', 'left');
+ axisHandled = true;
+ }
+ }
+ if (controller.axes[0] > deadZone) {
+ if (
+ currentTime - axisTimer.current >
+ maxSpeed + (2000 - Math.abs(controller.axes[0]) * 2000)
+ ) {
+ emit('analog', 'right');
+ axisHandled = true;
+ }
+ }
+ if (controller.axes[1] < -deadZone) {
+ if (
+ currentTime - axisTimer.current >
+ maxSpeed + (2000 - Math.abs(controller.axes[1]) * 2000)
+ ) {
+ emit('analog', 'up');
+ axisHandled = true;
+ }
+ }
+ if (controller.axes[1] > deadZone) {
+ if (
+ currentTime - axisTimer.current >
+ maxSpeed + (2000 - Math.abs(controller.axes[1]) * 2000)
+ ) {
+ emit('analog', 'down');
+ axisHandled = true;
+ }
+ }
+
+ if (axisHandled) axisTimer.current = currentTime;
+
+ let rightAxisHandled = false;
+
+ if (controller.axes.length > 2) {
+ if (controller.axes[2] < -deadZone) {
+ if (currentTime - axisTimerRight.current > maxSpeed + (2000 - Math.abs(controller.axes[2]) * 2000)) {
+ emit('analogRight', 'left');
+ rightAxisHandled = true;
+ }
+ }
+ if (controller.axes[2] > deadZone) {
+ if (currentTime - axisTimerRight.current > maxSpeed + (2000 - Math.abs(controller.axes[2]) * 2000)) {
+ emit('analogRight', 'right');
+ rightAxisHandled = true;
+ }
+ }
+ if (controller.axes[3] < -deadZone) {
+ if (currentTime - axisTimerRight.current > maxSpeed + (2000 - Math.abs(controller.axes[3]) * 2000)) {
+ emit('analogRight', 'up');
+ rightAxisHandled = true;
+ }
+ }
+ if (controller.axes[3] > deadZone) {
+ if (currentTime - axisTimerRight.current > maxSpeed + (2000 - Math.abs(controller.axes[3]) * 2000)) {
+ emit('analogRight', 'down');
+ rightAxisHandled = true;
+ }
+ }
+ }
+
+ if (rightAxisHandled) axisTimerRight.current = currentTime;
+ });
+ }
+ animationFrameId = requestAnimationFrame(updateStatus);
+ };
+
+ animationFrameId = requestAnimationFrame(updateStatus);
+
+ return () => {
+ cancelAnimationFrame(animationFrameId);
+ };
+ }, [enabled]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default GamepadProvider;
diff --git a/src/services/GamepadContext/index.tsx b/src/services/GamepadContext/index.tsx
new file mode 100644
index 000000000..f520f5be7
--- /dev/null
+++ b/src/services/GamepadContext/index.tsx
@@ -0,0 +1,11 @@
+// Copyright (C) 2017-2026 Smart code 203358507
+
+import GamepadProvider from './GamepadProvider';
+import useGamepad from './useGamepad';
+
+export type { ControllerType } from './GamepadContext';
+
+export {
+ GamepadProvider,
+ useGamepad
+};
diff --git a/src/services/GamepadContext/useGamepad.tsx b/src/services/GamepadContext/useGamepad.tsx
new file mode 100644
index 000000000..d3cf91b8d
--- /dev/null
+++ b/src/services/GamepadContext/useGamepad.tsx
@@ -0,0 +1,10 @@
+// Copyright (C) 2017-2026 Smart code 203358507
+
+import { useContext } from 'react';
+import GamepadContext from './GamepadContext';
+
+const useGamepad = () => {
+ return useContext(GamepadContext);
+};
+
+export default useGamepad;
diff --git a/src/services/GamepadNavigation/index.tsx b/src/services/GamepadNavigation/index.tsx
new file mode 100644
index 000000000..2c3199b0d
--- /dev/null
+++ b/src/services/GamepadNavigation/index.tsx
@@ -0,0 +1,11 @@
+// Copyright (C) 2017-2026 Smart code 203358507
+
+import useContentGamepadNavigation from './useContentGamepadNavigation';
+import useVerticalNavGamepadNavigation from './useVerticalNavGamepadNavigation';
+import useHorizontalNavGamepadNavigation from './useHorizontalNavGamepadNavigation';
+
+export {
+ useContentGamepadNavigation,
+ useVerticalNavGamepadNavigation,
+ useHorizontalNavGamepadNavigation,
+};
diff --git a/src/services/GamepadNavigation/useContentGamepadNavigation.tsx b/src/services/GamepadNavigation/useContentGamepadNavigation.tsx
new file mode 100644
index 000000000..a3df6880b
--- /dev/null
+++ b/src/services/GamepadNavigation/useContentGamepadNavigation.tsx
@@ -0,0 +1,144 @@
+// Copyright (C) 2017-2026 Smart code 203358507
+
+import { useEffect, useRef } from 'react';
+import { useGamepad } from '../GamepadContext';
+
+const FOCUSABLE = '[tabindex]:not([data-focus-guard])';
+
+const getActiveScope = (fallback: HTMLDivElement | null): HTMLElement | null => {
+ if (document.querySelector('[data-gamepad-modal]')) return null;
+
+ const modals = document.querySelectorAll('.modals-container');
+ for (const modal of modals) {
+ if (modal.children.length > 0) return modal;
+ }
+
+ const dropdown = fallback?.querySelector('[class*="dropdown"][class*="open"]');
+ if (dropdown) return dropdown;
+
+ return fallback;
+};
+
+const useContentGamepadNavigation = (
+ sectionRef: React.RefObject,
+ gamepadHandlerId: string
+) => {
+ const gamepad = useGamepad();
+ const lastFocused = useRef(null);
+ const wasInOverlay = useRef(false);
+
+ useEffect(() => {
+ const handleGamepadNavigation = (
+ direction: 'left' | 'right' | 'up' | 'down'
+ ) => {
+ const scope = getActiveScope(sectionRef.current);
+ const inOverlay = scope !== sectionRef.current;
+
+ if (inOverlay && !wasInOverlay.current) {
+ const focused = sectionRef.current?.querySelector(':focus');
+ if (focused) lastFocused.current = focused;
+ }
+ wasInOverlay.current = inOverlay;
+
+ const elements = Array.from(
+ scope?.querySelectorAll(FOCUSABLE) || []
+ );
+ if (elements.length === 0) return;
+
+ const activeElement = (scope ?? document)?.querySelector(':focus');
+
+ if (!activeElement) {
+ elements[0].focus();
+ return;
+ }
+
+ let closestElement: HTMLDivElement | null = null;
+ const cur = activeElement.getBoundingClientRect();
+ const cx = cur.left + cur.width / 2;
+ const cy = cur.top + cur.height / 2;
+ let closestDistance = Infinity;
+
+ elements.forEach((el) => {
+ if (el === activeElement) return;
+ const r = el.getBoundingClientRect();
+ const ex = r.left + r.width / 2;
+ const ey = r.top + r.height / 2;
+
+ const isCorrectDirection =
+ (direction === 'left' && ex < cx) ||
+ (direction === 'right' && ex > cx) ||
+ (direction === 'up' && ey < cy) ||
+ (direction === 'down' && ey > cy);
+
+ if (!isCorrectDirection) return;
+
+ const dx = ex - cx;
+ const dy = ey - cy;
+ const isHorizontal = direction === 'left' || direction === 'right';
+ const primary = isHorizontal ? Math.abs(dx) : Math.abs(dy);
+ const secondary = isHorizontal ? Math.abs(dy) : Math.abs(dx);
+ const distance = primary + secondary * 3;
+
+ if (distance < closestDistance) {
+ closestDistance = distance;
+ closestElement = el;
+ }
+ });
+
+ if (closestElement) {
+ closestElement.focus();
+ }
+ };
+
+ const onSelect = () => {
+ const scope = getActiveScope(sectionRef.current);
+ const inOverlay = scope !== sectionRef.current;
+
+ if (inOverlay && !wasInOverlay.current) {
+ const focused = sectionRef.current?.querySelector(':focus');
+ if (focused) lastFocused.current = focused;
+ }
+ wasInOverlay.current = inOverlay;
+
+ const elements = Array.from(
+ scope?.querySelectorAll(FOCUSABLE) || []
+ );
+ if (elements.length === 0) {
+ if (lastFocused.current) {
+ lastFocused.current.focus();
+ wasInOverlay.current = false;
+ }
+ return;
+ }
+
+ const activeElement = (scope ?? document)?.querySelector(':focus');
+
+ if (!activeElement) {
+ elements[0].focus();
+ return;
+ }
+ const isSelect = Array.from(activeElement.classList).some((cls) => cls.startsWith('select-input'));
+ if (!isSelect) {
+ activeElement?.click();
+
+ requestAnimationFrame(() => {
+ const stillInOverlay = getActiveScope(sectionRef.current) !== sectionRef.current;
+ if (!stillInOverlay && wasInOverlay.current && lastFocused.current) {
+ lastFocused.current.focus();
+ wasInOverlay.current = false;
+ }
+ });
+ }
+ };
+
+ gamepad?.on('analog', gamepadHandlerId, handleGamepadNavigation);
+ gamepad?.on('buttonA', gamepadHandlerId, onSelect);
+
+ return () => {
+ gamepad?.off('analog', gamepadHandlerId);
+ gamepad?.off('buttonA', gamepadHandlerId);
+ };
+ }, [gamepad, gamepadHandlerId, sectionRef]);
+};
+
+export default useContentGamepadNavigation;
diff --git a/src/services/GamepadNavigation/useHorizontalNavGamepadNavigation.tsx b/src/services/GamepadNavigation/useHorizontalNavGamepadNavigation.tsx
new file mode 100644
index 000000000..0d65a3fa1
--- /dev/null
+++ b/src/services/GamepadNavigation/useHorizontalNavGamepadNavigation.tsx
@@ -0,0 +1,24 @@
+// Copyright (C) 2017-2026 Smart code 203358507
+
+import { useEffect } from 'react';
+import { useGamepad } from '../GamepadContext';
+import useFullscreen from 'stremio/common/Fullscreen';
+
+const useHorizontalNavGamepadNavigation = (gamepadHandlerId: string, enableGoBack: boolean) => {
+ const gamepad = useGamepad();
+ const [fullscreen,,,toggleFullscreen] = useFullscreen();
+
+ useEffect(() => {
+ const goBack = () => enableGoBack && window.history.back();
+
+ gamepad?.on('buttonY', gamepadHandlerId, toggleFullscreen as () => void);
+ gamepad?.on('buttonB', gamepadHandlerId, goBack);
+
+ return () => {
+ gamepad?.off('buttonY', gamepadHandlerId);
+ gamepad?.off('buttonB', gamepadHandlerId);
+ };
+ }, [gamepad, gamepadHandlerId, enableGoBack, fullscreen]);
+};
+
+export default useHorizontalNavGamepadNavigation;
diff --git a/src/services/GamepadNavigation/useVerticalNavGamepadNavigation.tsx b/src/services/GamepadNavigation/useVerticalNavGamepadNavigation.tsx
new file mode 100644
index 000000000..041b4e66c
--- /dev/null
+++ b/src/services/GamepadNavigation/useVerticalNavGamepadNavigation.tsx
@@ -0,0 +1,35 @@
+// Copyright (C) 2017-2026 Smart code 203358507
+
+import { useEffect } from 'react';
+import { useGamepad } from '../GamepadContext';
+
+const ROUTES = ['search', 'board', 'discover', 'library', 'calendar', 'addons', 'settings'];
+
+const useVerticalGamepadNavigation = (_sectionRef: React.RefObject, currentRoute: string) => {
+ const gamepad = useGamepad();
+
+ useEffect(() => {
+ const navigate = (direction: 'prev' | 'next') => {
+ const currentIndex = ROUTES.indexOf(currentRoute);
+ if (currentIndex === -1) return;
+
+ let nextIndex = currentIndex;
+ if (direction === 'next') nextIndex = Math.min(currentIndex + 1, ROUTES.length - 1);
+ if (direction === 'prev') nextIndex = Math.max(currentIndex - 1, 0);
+
+ if (nextIndex !== currentIndex) {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: String(nextIndex), code: `Digit${nextIndex}`, bubbles: true }));
+ }
+ };
+
+ gamepad?.on('buttonLT', currentRoute, () => navigate('prev'));
+ gamepad?.on('buttonRT', currentRoute, () => navigate('next'));
+
+ return () => {
+ gamepad?.off('buttonLT', currentRoute);
+ gamepad?.off('buttonRT', currentRoute);
+ };
+ }, [gamepad, currentRoute]);
+};
+
+export default useVerticalGamepadNavigation;
diff --git a/src/services/index.js b/src/services/index.js
index 84cfcc8b8..3a3b0128a 100644
--- a/src/services/index.js
+++ b/src/services/index.js
@@ -5,6 +5,7 @@ const Core = require('./Core');
const DragAndDrop = require('./DragAndDrop');
const KeyboardShortcuts = require('./KeyboardShortcuts');
const { ServicesProvider, useServices } = require('./ServicesContext');
+const { GamepadProvider, useGamepad } = require('./GamepadContext');
const Shell = require('./Shell');
module.exports = {
@@ -14,5 +15,7 @@ module.exports = {
KeyboardShortcuts,
ServicesProvider,
useServices,
- Shell
+ Shell,
+ GamepadProvider,
+ useGamepad,
};
diff --git a/src/types/models/Ctx.d.ts b/src/types/models/Ctx.d.ts
index 88bc77a12..3ff7b97e4 100644
--- a/src/types/models/Ctx.d.ts
+++ b/src/types/models/Ctx.d.ts
@@ -25,6 +25,7 @@ type Settings = {
interfaceScale: number,
quitOnClose: boolean,
hideSpoilers: boolean,
+ gamepadSupport: boolean,
nextVideoNotificationDuration: number,
playInBackground: boolean,
playerType: string | null,
diff --git a/src/types/models/Player.d.ts b/src/types/models/Player.d.ts
index 321127316..c83f54b34 100644
--- a/src/types/models/Player.d.ts
+++ b/src/types/models/Player.d.ts
@@ -5,7 +5,6 @@ type LibraryItemPlayer = Pick & {
type VideoPlayer = Video & {
upcoming: boolean,
watched: boolean,
- progress: boolean | null,
scheduled: boolean,
deepLinks: VideoDeepLinks,
};
@@ -16,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 = {
@@ -33,6 +35,7 @@ type SeriesInfo = {
type SubtitlesTrackState = {
id: string,
embedded: boolean,
+ lang?: string,
};
type AudioTrackState = {
@@ -40,7 +43,7 @@ type AudioTrackState = {
};
type StreamState = {
- subtitleTrack?: SubtitlesTrackState,
+ subtitleTrack?: SubtitlesTrackState | null,
subtitleDelay?: number,
subtitleSize?: number,
subtitleOffset?: number,