feat: fullscreen support on safari

This commit is contained in:
Timothy Z. 2026-05-04 21:53:44 +03:00
parent 6065d8bf15
commit 671170a9da
7 changed files with 223 additions and 190 deletions

View file

@ -7,6 +7,8 @@ export type FullscreenContextValue = readonly [
requestFullscreen: () => Promise<void> | void, requestFullscreen: () => Promise<void> | void,
exitFullscreen: () => void, exitFullscreen: () => void,
toggleFullscreen: () => void, toggleFullscreen: () => void,
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';
@ -21,35 +21,46 @@ const isTextInputFocused = () => {
activeElement.isContentEditable); activeElement.isContentEditable);
}; };
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 { await document.documentElement.requestFullscreen();
await document.documentElement.requestFullscreen(); } else if (videoElementRef.current && hasWebkitFullscreen) {
} catch (err) { (videoElementRef.current as any).webkitEnterFullscreen();
console.error('Error enabling fullscreen', err);
}
} }
}, [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]);
@ -95,8 +106,8 @@ const FullscreenProvider = ({ children }: Props) => {
}, [shell, toggleFullscreen, exitFullscreen, escExitFullscreen]); }, [shell, toggleFullscreen, exitFullscreen, escExitFullscreen]);
const value = useMemo<FullscreenContextValue>( const value = useMemo<FullscreenContextValue>(
() => [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen], () => [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen, supported, setVideoElement],
[fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen] [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen, supported, setVideoElement]
); );
return ( return (

View file

@ -6,7 +6,6 @@ const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react'); const { default: Icon } = require('@stremio/stremio-icons/react');
const { Button, Image } = require('stremio/components'); const { Button, Image } = require('stremio/components');
const { useFullscreen } = require('stremio/common/Fullscreen'); const { useFullscreen } = require('stremio/common/Fullscreen');
const usePWA = require('stremio/common/usePWA');
const { useHorizontalNavGamepadNavigation } = require('stremio/services/GamepadNavigation'); const { useHorizontalNavGamepadNavigation } = require('stremio/services/GamepadNavigation');
const SearchBar = require('./SearchBar'); const SearchBar = require('./SearchBar');
const NavMenu = require('./NavMenu'); const NavMenu = require('./NavMenu');
@ -17,8 +16,7 @@ const HorizontalNavBar = React.memo(({ className, route, query, title, backButto
const backButtonOnClick = React.useCallback(() => { const backButtonOnClick = React.useCallback(() => {
window.history.back(); window.history.back();
}, []); }, []);
const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen(); const [fullscreen, requestFullscreen, exitFullscreen, , supported] = useFullscreen();
const [isIOSPWA] = usePWA();
const renderNavMenuLabel = React.useCallback(({ ref, className, onClick, children, }) => ( const renderNavMenuLabel = React.useCallback(({ ref, className, onClick, children, }) => (
<Button ref={ref} className={classnames(className, styles['button-container'], styles['menu-button-container'])} tabIndex={-1} onClick={onClick}> <Button ref={ref} className={classnames(className, styles['button-container'], styles['menu-button-container'])} tabIndex={-1} onClick={onClick}>
<Icon className={styles['icon']} name={'person-outline'} /> <Icon className={styles['icon']} name={'person-outline'} />
@ -64,7 +62,7 @@ const HorizontalNavBar = React.memo(({ className, route, query, title, backButto
null null
} }
{ {
!isIOSPWA && fullscreenButton ? supported && fullscreenButton ?
<Button className={styles['button-container']} title={fullscreen ? t('EXIT_FULLSCREEN') : t('ENTER_FULLSCREEN')} tabIndex={-1} onClick={fullscreen ? exitFullscreen : requestFullscreen}> <Button className={styles['button-container']} title={fullscreen ? t('EXIT_FULLSCREEN') : t('ENTER_FULLSCREEN')} tabIndex={-1} onClick={fullscreen ? exitFullscreen : requestFullscreen}>
<Icon className={styles['icon']} name={fullscreen ? 'minimize' : 'maximize'} /> <Icon className={styles['icon']} name={fullscreen ? 'minimize' : 'maximize'} />
</Button> </Button>

View file

@ -23,8 +23,8 @@ const NavMenuContent = ({ onClick }) => {
const streamingServer = useStreamingServer(); const streamingServer = useStreamingServer();
const { handlePlayUrl } = usePlayUrl(); const { handlePlayUrl } = usePlayUrl();
const toast = useToast(); const toast = useToast();
const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen(); const [fullscreen, requestFullscreen, exitFullscreen, , supported] = useFullscreen();
const [isIOSPWA, isAndroidPWA] = usePWA(); const [, isAndroidPWA] = usePWA();
const streamingServerWarningDismissed = React.useMemo(() => { const streamingServerWarningDismissed = React.useMemo(() => {
return streamingServer.settings !== null && streamingServer.settings.type === 'Ready' || ( return streamingServer.settings !== null && streamingServer.settings.type === 'Ready' || (
!isNaN(profile.settings.streamingServerWarningDismissed.getTime()) && !isNaN(profile.settings.streamingServerWarningDismissed.getTime()) &&
@ -79,7 +79,7 @@ const NavMenuContent = ({ onClick }) => {
</div> </div>
</div> </div>
{ {
!isIOSPWA && !isAndroidPWA ? supported && !isAndroidPWA ?
<div className={styles['nav-menu-section']}> <div className={styles['nav-menu-section']}>
<Button className={styles['nav-menu-option-container']} title={fullscreen ? t('EXIT_FULLSCREEN') : t('ENTER_FULLSCREEN')} onClick={fullscreen ? exitFullscreen : requestFullscreen}> <Button className={styles['nav-menu-option-container']} title={fullscreen ? t('EXIT_FULLSCREEN') : t('ENTER_FULLSCREEN')} onClick={fullscreen ? exitFullscreen : requestFullscreen}>
<Icon className={styles['icon']} name={fullscreen ? 'minimize' : 'maximize'} /> <Icon className={styles['icon']} name={fullscreen ? 'minimize' : 'maximize'} />

View file

@ -68,7 +68,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);
@ -534,7 +540,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) => {
@ -780,181 +786,181 @@ const Player = ({ urlParams, queryParams }) => {
onMouseMove={onContainerMouseMove} onMouseMove={onContainerMouseMove}
onMouseOver={onContainerMouseMove} onMouseOver={onContainerMouseMove}
onMouseLeave={onContainerMouseLeave}> onMouseLeave={onContainerMouseLeave}>
<Video <Video
ref={video.containerRef} ref={video.containerRef}
className={styles['layer']} className={styles['layer']}
onClick={onVideoClick} onClick={onVideoClick}
onDoubleClick={onVideoDoubleClick} onDoubleClick={onVideoDoubleClick}
/>
{
!video.state.loaded ?
<div className={classnames(styles['layer'], styles['background-layer'])}>
<img className={styles['image']} src={player?.metaItem?.content?.background} />
</div>
:
null
}
{
(video.state.buffering || !video.state.loaded) && !error ?
<BufferingLoader
ref={bufferingRef}
className={classnames(styles['layer'], styles['buffering-layer'])}
logo={player?.metaItem?.content?.logo}
/>
:
null
}
{
error !== null ?
<Error
ref={errorRef}
className={classnames(styles['layer'], styles['error-layer'])}
stream={video.state.stream}
{...error}
/>
:
null
}
{
menusOpen ?
<div className={styles['layer']} />
:
null
}
{
video.state.volume !== null && overlayHidden ?
<VolumeChangeIndicator
muted={video.state.muted}
volume={video.state.volume}
/>
:
null
}
<ContextMenu on={[video.containerRef, bufferingRef, errorRef]} autoClose>
<OptionsMenu
className={classnames(styles['layer'], styles['menu-layer'])}
stream={player?.selected?.stream}
playbackDevices={playbackDevices}
extraSubtitlesTracks={extraSubtitleTracks}
selectedExtraSubtitlesTrackId={selectedExtraSubtitleTrackId}
/> />
</ContextMenu> {
<HorizontalNavBar !video.state.loaded ?
className={classnames(styles['layer'], styles['nav-bar-layer'])} <div className={classnames(styles['layer'], styles['background-layer'])}>
title={player.title !== null ? player.title : ''} <img className={styles['image']} src={player?.metaItem?.content?.background} />
backButton={true} </div>
fullscreenButton={true} :
hdrInfo={video.state.hdrInfo} null
onMouseMove={onBarMouseMove} }
onMouseOver={onBarMouseMove} {
/> (video.state.buffering || !video.state.loaded) && !error ?
{ <BufferingLoader
player.metaItem?.type === 'Ready' ? ref={bufferingRef}
<SideDrawerButton className={classnames(styles['layer'], styles['buffering-layer'])}
className={classnames(styles['layer'], styles['side-drawer-button-layer'])} logo={player?.metaItem?.content?.logo}
onClick={toggleSideDrawer} />
/> :
: null
null }
} {
<ControlBar error !== null ?
ref={controlBarRef} <Error
className={classnames(styles['layer'], styles['control-bar-layer'])} ref={errorRef}
paused={video.state.paused} className={classnames(styles['layer'], styles['error-layer'])}
time={video.state.time} stream={video.state.stream}
duration={video.state.duration} {...error}
buffered={video.state.buffered} />
volume={video.state.volume} :
muted={video.state.muted} null
playbackSpeed={video.state.playbackSpeed} }
subtitlesTracks={allSubtitleTracks} {
audioTracks={video.state.audioTracks} menusOpen ?
metaItem={player.metaItem} <div className={styles['layer']} />
nextVideo={player.nextVideo} :
stream={player.selected !== null ? player.selected.stream : null} null
statistics={statistics} }
onPlayRequested={onPlayRequested} {
onPauseRequested={onPauseRequested} video.state.volume !== null && overlayHidden ?
onNextVideoRequested={onNextVideoRequested} <VolumeChangeIndicator
onMuteRequested={onMuteRequested} muted={video.state.muted}
onUnmuteRequested={onUnmuteRequested} volume={video.state.volume}
onVolumeChangeRequested={onVolumeChangeRequested} />
onSeekRequested={onSeekRequested} :
onToggleOptionsMenu={toggleOptionsMenu} null
onToggleSubtitlesMenu={toggleSubtitlesMenu} }
onToggleAudioMenu={toggleAudioMenu} <ContextMenu on={[video.containerRef, bufferingRef, errorRef]} autoClose>
onToggleSpeedMenu={toggleSpeedMenu} <OptionsMenu
videoScale={video.state.videoScale}
videoScaleLabel={VIDEO_SCALE_LABELS[video.state.videoScale || 'contain']}
onVideoScaleChanged={onVideoScaleChanged}
onToggleStatisticsMenu={toggleStatisticsMenu}
onToggleSideDrawer={toggleSideDrawer}
onMouseMove={onBarMouseMove}
onMouseOver={onBarMouseMove}
onTouchEnd={onContainerMouseLeave}
/>
<Indicator
className={classnames(styles['layer'], styles['indicator-layer'])}
videoState={video.state}
disabled={subtitlesMenuOpen}
/>
{
nextVideoPopupOpen ?
<NextVideoPopup
className={classnames(styles['layer'], styles['menu-layer'])} className={classnames(styles['layer'], styles['menu-layer'])}
metaItem={player.metaItem !== null && player.metaItem.type === 'Ready' ? player.metaItem.content : null} stream={player?.selected?.stream}
nextVideo={player.nextVideo} playbackDevices={playbackDevices}
onDismiss={onDismissNextVideoPopup} extraSubtitlesTracks={extraSubtitleTracks}
onNextVideoRequested={onNextVideoRequested} selectedExtraSubtitlesTrackId={selectedExtraSubtitleTrackId}
/> />
: </ContextMenu>
null <HorizontalNavBar
} className={classnames(styles['layer'], styles['nav-bar-layer'])}
<Transition when={statisticsMenuOpen} name={'fade'}> title={player.title !== null ? player.title : ''}
<StatisticsMenu backButton={true}
className={classnames(styles['layer'], styles['menu-layer'])} fullscreenButton={true}
{...statistics} hdrInfo={video.state.hdrInfo}
onMouseMove={onBarMouseMove}
onMouseOver={onBarMouseMove}
/> />
</Transition> {
<Transition when={sideDrawerOpen} name={'slide-left'}> player.metaItem?.type === 'Ready' ?
<SideDrawer <SideDrawerButton
className={classnames(styles['layer'], styles['side-drawer-layer'])} className={classnames(styles['layer'], styles['side-drawer-button-layer'])}
metaItem={player.metaItem?.content} onClick={toggleSideDrawer}
seriesInfo={player.seriesInfo} />
closeSideDrawer={closeSideDrawer} :
selected={player.selected?.streamRequest?.path.id} null
/> }
</Transition> <ControlBar
<Transition when={subtitlesMenuOpen} name={'fade'}> ref={controlBarRef}
<SubtitlesMenu className={classnames(styles['layer'], styles['control-bar-layer'])}
className={classnames(styles['layer'], styles['menu-layer'])} paused={video.state.paused}
{...subtitlesMenuProps} time={video.state.time}
/> duration={video.state.duration}
</Transition> buffered={video.state.buffered}
<Transition when={audioMenuOpen} name={'fade'}> volume={video.state.volume}
<AudioMenu muted={video.state.muted}
className={classnames(styles['layer'], styles['menu-layer'])}
audioTracks={video.state.audioTracks}
selectedAudioTrackId={video.state.selectedAudioTrackId}
onAudioTrackSelected={onAudioTrackSelected}
/>
</Transition>
<Transition when={speedMenuOpen} name={'fade'}>
<SpeedMenu
className={classnames(styles['layer'], styles['menu-layer'])}
playbackSpeed={video.state.playbackSpeed} playbackSpeed={video.state.playbackSpeed}
onPlaybackSpeedChanged={onPlaybackSpeedChanged} subtitlesTracks={allSubtitleTracks}
audioTracks={video.state.audioTracks}
metaItem={player.metaItem}
nextVideo={player.nextVideo}
stream={player.selected !== null ? player.selected.stream : null}
statistics={statistics}
onPlayRequested={onPlayRequested}
onPauseRequested={onPauseRequested}
onNextVideoRequested={onNextVideoRequested}
onMuteRequested={onMuteRequested}
onUnmuteRequested={onUnmuteRequested}
onVolumeChangeRequested={onVolumeChangeRequested}
onSeekRequested={onSeekRequested}
onToggleOptionsMenu={toggleOptionsMenu}
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}
onMouseOver={onBarMouseMove}
onTouchEnd={onContainerMouseLeave}
/> />
</Transition> <Indicator
<Transition when={optionsMenuOpen} name={'fade'}> className={classnames(styles['layer'], styles['indicator-layer'])}
<OptionsMenu videoState={video.state}
className={classnames(styles['layer'], styles['menu-layer'])} disabled={subtitlesMenuOpen}
stream={player.selected?.stream}
playbackDevices={playbackDevices}
extraSubtitlesTracks={extraSubtitleTracks}
selectedExtraSubtitlesTrackId={selectedExtraSubtitleTrackId}
/> />
</Transition> {
nextVideoPopupOpen ?
<NextVideoPopup
className={classnames(styles['layer'], styles['menu-layer'])}
metaItem={player.metaItem !== null && player.metaItem.type === 'Ready' ? player.metaItem.content : null}
nextVideo={player.nextVideo}
onDismiss={onDismissNextVideoPopup}
onNextVideoRequested={onNextVideoRequested}
/>
:
null
}
<Transition when={statisticsMenuOpen} name={'fade'}>
<StatisticsMenu
className={classnames(styles['layer'], styles['menu-layer'])}
{...statistics}
/>
</Transition>
<Transition when={sideDrawerOpen} name={'slide-left'}>
<SideDrawer
className={classnames(styles['layer'], styles['side-drawer-layer'])}
metaItem={player.metaItem?.content}
seriesInfo={player.seriesInfo}
closeSideDrawer={closeSideDrawer}
selected={player.selected?.streamRequest?.path.id}
/>
</Transition>
<Transition when={subtitlesMenuOpen} name={'fade'}>
<SubtitlesMenu
className={classnames(styles['layer'], styles['menu-layer'])}
{...subtitlesMenuProps}
/>
</Transition>
<Transition when={audioMenuOpen} name={'fade'}>
<AudioMenu
className={classnames(styles['layer'], styles['menu-layer'])}
audioTracks={video.state.audioTracks}
selectedAudioTrackId={video.state.selectedAudioTrackId}
onAudioTrackSelected={onAudioTrackSelected}
/>
</Transition>
<Transition when={speedMenuOpen} name={'fade'}>
<SpeedMenu
className={classnames(styles['layer'], styles['menu-layer'])}
playbackSpeed={video.state.playbackSpeed}
onPlaybackSpeedChanged={onPlaybackSpeedChanged}
/>
</Transition>
<Transition when={optionsMenuOpen} name={'fade'}>
<OptionsMenu
className={classnames(styles['layer'], styles['menu-layer'])}
stream={player.selected?.stream}
playbackDevices={playbackDevices}
extraSubtitlesTracks={extraSubtitleTracks}
selectedExtraSubtitlesTrackId={selectedExtraSubtitleTrackId}
/>
</Transition>
</div> </div>
); );
}; };

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,
}; };
}; };