Merge pull request #1260 from Stremio/feat/support-safari-fullscreen-video-mode
Some checks are pending
Build / build (push) Waiting to run

Player: Add fullscreen support on safari
This commit is contained in:
Timothy Z. 2026-05-10 11:06:02 +03:00 committed by GitHub
commit 90bd2c9c3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 65 additions and 19 deletions

View file

@ -19,7 +19,7 @@
"@stremio/stremio-colors": "5.2.0", "@stremio/stremio-colors": "5.2.0",
"@stremio/stremio-core-web": "0.57.0", "@stremio/stremio-core-web": "0.57.0",
"@stremio/stremio-icons": "5.10.0", "@stremio/stremio-icons": "5.10.0",
"@stremio/stremio-video": "0.0.78", "@stremio/stremio-video": "0.0.79",
"a-color-picker": "1.2.1", "a-color-picker": "1.2.1",
"bowser": "2.14.1", "bowser": "2.14.1",
"buffer": "6.0.3", "buffer": "6.0.3",

View file

@ -24,8 +24,8 @@ importers:
specifier: 5.10.0 specifier: 5.10.0
version: 5.10.0 version: 5.10.0
'@stremio/stremio-video': '@stremio/stremio-video':
specifier: 0.0.78 specifier: 0.0.79
version: 0.0.78 version: 0.0.79
a-color-picker: a-color-picker:
specifier: 1.2.1 specifier: 1.2.1
version: 1.2.1 version: 1.2.1
@ -1400,8 +1400,8 @@ packages:
'@stremio/stremio-icons@5.10.0': '@stremio/stremio-icons@5.10.0':
resolution: {integrity: sha512-Zw/vGC3D2yeQfk8xv/tfMJTDvbCPOI91tBg4XpR2+EgbZSX8Xvm7Vz457PIhFPhTAwdOPHp0VX0M3gzjbt0zOg==} resolution: {integrity: sha512-Zw/vGC3D2yeQfk8xv/tfMJTDvbCPOI91tBg4XpR2+EgbZSX8Xvm7Vz457PIhFPhTAwdOPHp0VX0M3gzjbt0zOg==}
'@stremio/stremio-video@0.0.78': '@stremio/stremio-video@0.0.79':
resolution: {integrity: sha512-Kz6uj20vpyDZoFy/kEbim4l+TxyRKl9STjGqPT3cIBdpG2e+GhJuaBVkF1liL7vZvSQA8EPzLbBqwxhP92Ikvg==} resolution: {integrity: sha512-RfxmeG+rQdoflfqBybRB9vSqb3cjc3Fd5T9uNNUbwC287apQsNyjYOlvk7sfRlQ1Za/UdFsyoqR5vGBSBDzlhQ==}
'@stylistic/eslint-plugin-jsx@4.4.1': '@stylistic/eslint-plugin-jsx@4.4.1':
resolution: {integrity: sha512-83SInq4u7z71vWwGG+6ViOtlOmZ6tSrDkMPhrvdBBTGMLA0gs22WSdhQ4vZP3oJ5Xg4ythvqeUiFSedvVxzhyA==} resolution: {integrity: sha512-83SInq4u7z71vWwGG+6ViOtlOmZ6tSrDkMPhrvdBBTGMLA0gs22WSdhQ4vZP3oJ5Xg4ythvqeUiFSedvVxzhyA==}
@ -6450,7 +6450,7 @@ snapshots:
'@stremio/stremio-icons@5.10.0': {} '@stremio/stremio-icons@5.10.0': {}
'@stremio/stremio-video@0.0.78': '@stremio/stremio-video@0.0.79':
dependencies: dependencies:
buffer: 6.0.3 buffer: 6.0.3
color: 4.2.3 color: 4.2.3

View file

@ -8,6 +8,7 @@ export type FullscreenContextValue = readonly [
exitFullscreen: () => void, exitFullscreen: () => void,
toggleFullscreen: () => void, toggleFullscreen: () => void,
supported: boolean, supported: boolean,
setVideoElement: (el: HTMLVideoElement | null) => void,
]; ];
const FullscreenContext = createContext<FullscreenContextValue | null>(null); const FullscreenContext = createContext<FullscreenContextValue | null>(null);

View file

@ -1,6 +1,6 @@
// Copyright (C) 2017-2026 Smart code 203358507 // Copyright (C) 2017-2026 Smart code 203358507
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { withCoreSuspender } from '../CoreSuspender'; import { withCoreSuspender } from '../CoreSuspender';
import onShortcut from '../Shortcuts/onShortcut'; import onShortcut from '../Shortcuts/onShortcut';
import useSettings from '../useSettings'; import useSettings from '../useSettings';
@ -11,35 +11,50 @@ type Props = {
children: React.ReactNode, children: React.ReactNode,
}; };
const hasWebkitFullscreen = typeof HTMLVideoElement !== 'undefined' &&
typeof HTMLVideoElement.prototype.webkitEnterFullscreen === 'function';
const FullscreenProvider = ({ children }: Props) => { const FullscreenProvider = ({ children }: Props) => {
const shell = useShell(); const shell = useShell();
const [settings] = useSettings(); const [settings] = useSettings();
const escExitFullscreen = settings.escExitFullscreen; const escExitFullscreen = settings.escExitFullscreen;
const videoElementRef = useRef<HTMLVideoElement | null>(null);
const [hasVideoElement, setHasVideoElement] = useState(false);
const [fullscreen, setFullscreen] = useState<boolean>(() => { const [fullscreen, setFullscreen] = useState<boolean>(() => {
if (typeof document === 'undefined') return false; if (typeof document === 'undefined') return false;
return document.fullscreenElement === document.documentElement; return document.fullscreenElement === document.documentElement;
}); });
const setVideoElement = useCallback((el: HTMLVideoElement | null) => {
videoElementRef.current = el;
setHasVideoElement(el !== null);
}, []);
const supported = shell.active || document.fullscreenEnabled === true || (hasVideoElement && hasWebkitFullscreen);
const requestFullscreen = useCallback(async () => { const requestFullscreen = useCallback(async () => {
if (shell.active) { if (shell.active) {
shell.send('win-set-visibility', { fullscreen: true }); shell.send('win-set-visibility', { fullscreen: true });
} else { } else if (document.fullscreenEnabled) {
try { try {
await document.documentElement.requestFullscreen(); await document.documentElement.requestFullscreen();
} catch (err) { } catch (err) {
console.error('Error enabling fullscreen', err); console.error('Error enabling fullscreen', err);
} }
} else if (videoElementRef.current && hasWebkitFullscreen) {
(videoElementRef.current as any).webkitEnterFullscreen();
} }
}, [shell]); }, [shell]);
const exitFullscreen = useCallback(() => { const exitFullscreen = useCallback(() => {
if (shell.active) { if (shell.active) {
shell.send('win-set-visibility', { fullscreen: false }); shell.send('win-set-visibility', { fullscreen: false });
} else { } else if (document.fullscreenElement === document.documentElement) {
if (document.fullscreenElement === document.documentElement) { document.exitFullscreen();
document.exitFullscreen(); } else if (videoElementRef.current && (videoElementRef.current as any).webkitDisplayingFullscreen) {
} (videoElementRef.current as any).webkitExitFullscreen();
} }
}, [shell]); }, [shell]);
@ -50,6 +65,8 @@ const FullscreenProvider = ({ children }: Props) => {
onShortcut('fullscreen', toggleFullscreen, [toggleFullscreen]); onShortcut('fullscreen', toggleFullscreen, [toggleFullscreen]);
useEffect(() => { useEffect(() => {
const videoElement = videoElementRef.current;
const onWindowVisibilityChanged = (state: WindowVisibility) => { const onWindowVisibilityChanged = (state: WindowVisibility) => {
setFullscreen(state.isFullscreen === true); setFullscreen(state.isFullscreen === true);
}; };
@ -58,6 +75,10 @@ const FullscreenProvider = ({ children }: Props) => {
setFullscreen(document.fullscreenElement === document.documentElement); setFullscreen(document.fullscreenElement === document.documentElement);
}; };
const onWebkitFullscreenChange = () => {
setFullscreen((videoElement as any)?.webkitDisplayingFullscreen === true);
};
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.code === 'Escape' && escExitFullscreen) { if (event.code === 'Escape' && escExitFullscreen) {
exitFullscreen(); exitFullscreen();
@ -71,19 +92,21 @@ const FullscreenProvider = ({ children }: Props) => {
shell.on('win-visibility-changed', onWindowVisibilityChanged); shell.on('win-visibility-changed', onWindowVisibilityChanged);
document.addEventListener('keydown', onKeyDown); document.addEventListener('keydown', onKeyDown);
document.addEventListener('fullscreenchange', onFullscreenChange); document.addEventListener('fullscreenchange', onFullscreenChange);
videoElement?.addEventListener('webkitbeginfullscreen', onWebkitFullscreenChange);
videoElement?.addEventListener('webkitendfullscreen', onWebkitFullscreenChange);
return () => { return () => {
shell.off('win-visibility-changed', onWindowVisibilityChanged); shell.off('win-visibility-changed', onWindowVisibilityChanged);
document.removeEventListener('keydown', onKeyDown); document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('fullscreenchange', onFullscreenChange); document.removeEventListener('fullscreenchange', onFullscreenChange);
videoElement?.removeEventListener('webkitbeginfullscreen', onWebkitFullscreenChange);
videoElement?.removeEventListener('webkitendfullscreen', onWebkitFullscreenChange);
}; };
}, [shell, toggleFullscreen, exitFullscreen, escExitFullscreen]); }, [shell, toggleFullscreen, exitFullscreen, escExitFullscreen, hasVideoElement]);
const supported = shell.active || document.fullscreenEnabled === true;
const value = useMemo<FullscreenContextValue>( const value = useMemo<FullscreenContextValue>(
() => [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen, supported], () => [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen, supported, setVideoElement],
[fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen, supported] [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen, supported, setVideoElement]
); );
return ( return (

View file

@ -70,7 +70,13 @@ const Player = ({ urlParams, queryParams }) => {
const [immersed, setImmersed] = React.useState(true); const [immersed, setImmersed] = React.useState(true);
const setImmersedDebounced = React.useCallback(debounce(setImmersed, 3000), []); const setImmersedDebounced = React.useCallback(debounce(setImmersed, 3000), []);
const [, , , toggleFullscreen] = useFullscreen(); const [fullscreen, , , toggleFullscreen, , setVideoElement] = useFullscreen();
React.useEffect(() => {
const el = video.containerRef.current?.querySelector('video');
setVideoElement(el || null);
return () => setVideoElement(null);
}, [video.state.manifest]);
const [optionsMenuOpen, , closeOptionsMenu, toggleOptionsMenu] = useBinaryState(false); const [optionsMenuOpen, , closeOptionsMenu, toggleOptionsMenu] = useBinaryState(false);
const [subtitlesMenuOpen, , closeSubtitlesMenu, toggleSubtitlesMenu] = useBinaryState(false); const [subtitlesMenuOpen, , closeSubtitlesMenu, toggleSubtitlesMenu] = useBinaryState(false);
@ -536,7 +542,7 @@ const Player = ({ urlParams, queryParams }) => {
} }
}, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]); }, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]);
useMediaSession(video.state, player, onPlayRequested, onPauseRequested, onNextVideoRequested); useMediaSession(video.state, player, fullscreen, onPlayRequested, onPauseRequested, onNextVideoRequested);
React.useEffect(() => { React.useEffect(() => {
const onMediaKey = (action) => { const onMediaKey = (action) => {

View file

@ -5,12 +5,22 @@ import { MediaStatus } from 'stremio/common/useShell';
const useMediaSession = ( const useMediaSession = (
videoState: VideoState, videoState: VideoState,
player: Player, player: Player,
fullscreen: boolean,
onPlayRequested: () => void, onPlayRequested: () => void,
onPauseRequested: () => void, onPauseRequested: () => void,
onNextVideoRequested: () => void, onNextVideoRequested: () => void,
) => { ) => {
const shell = useShell(); const shell = useShell();
useEffect(() => {
if (!('audioSession' in navigator)) return;
const audioSession = (navigator as any).audioSession;
audioSession.type = fullscreen ? 'ambient' : 'playback';
return () => {
audioSession.type = 'playback';
};
}, [fullscreen]);
// Playback state // Playback state
useEffect(() => { useEffect(() => {
if (navigator.mediaSession) { if (navigator.mediaSession) {

View file

@ -40,6 +40,7 @@ const useVideo = () => {
extraSubtitlesTextColor: null, extraSubtitlesTextColor: null,
extraSubtitlesBackgroundColor: null, extraSubtitlesBackgroundColor: null,
extraSubtitlesOutlineColor: null, extraSubtitlesOutlineColor: null,
fullscreen: null,
}); });
const dispatch = (action, options) => { const dispatch = (action, options) => {
@ -147,6 +148,10 @@ const useVideo = () => {
setProp('videoScale', scale); setProp('videoScale', scale);
}; };
const setFullscreen = (state) => {
setProp('fullscreen', state);
};
const setSubtitlesTextColor = (color) => { const setSubtitlesTextColor = (color) => {
setProp('subtitlesTextColor', color); setProp('subtitlesTextColor', color);
setProp('extraSubtitlesTextColor', color); setProp('extraSubtitlesTextColor', color);
@ -244,6 +249,7 @@ const useVideo = () => {
setSubtitlesOutlineColor, setSubtitlesOutlineColor,
setExtraSubtitlesTrack, setExtraSubtitlesTrack,
setVideoScale, setVideoScale,
setFullscreen,
}; };
}; };