fix(player): re-enable controls auto-hide, keep open while settings popover is open

Tapping to reveal the controls showed them permanently — they never auto-hid
after the inactivity timeout (CONTROLS_CONSTANTS.TIMEOUT). Root cause:
useControlsTimeout was called with a hardcoded disabled: true (introduced in
the KSPlayer work in #1266), so the auto-hide timer was never armed.

Simply removing that override re-introduced a regression on iOS: the timer
fired while the settings popover was open, dismissing it mid-interaction
(subtitle / audio / speed selection), because on iOS the popover lives inside
the controls and closes when they fade out.

Surface the popover's open state through a small ControlsContext (rather than
prop drilling through HeaderControls) and feed it to useControlsTimeout's
disabled flag, so auto-hide pauses while the menu is open and re-arms once it
closes. Android's menu is a separate global bottom-sheet modal, unaffected.

Fixes #1243. Regression from #1266. Depends on #1621.
This commit is contained in:
Gauvino
2026-06-16 11:27:14 +02:00
parent fef1e7f122
commit ac0018092a
3 changed files with 154 additions and 115 deletions

View File

@@ -4,7 +4,7 @@ import type {
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useLocalSearchParams } from "expo-router";
import { type FC, useCallback, useEffect, useState } from "react";
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import { StyleSheet, useWindowDimensions, View } from "react-native";
import Animated, {
Easing,
@@ -30,6 +30,7 @@ import { ticksToMs } from "@/utils/time";
import { BottomControls } from "./BottomControls";
import { CenterControls } from "./CenterControls";
import { CONTROLS_CONSTANTS } from "./constants";
import { ControlsProvider } from "./contexts/ControlsContext";
import { EpisodeList } from "./EpisodeList";
import { GestureOverlay } from "./GestureOverlay";
import { HeaderControls } from "./HeaderControls";
@@ -110,6 +111,12 @@ export const Controls: FC<Props> = ({
const [episodeView, setEpisodeView] = useState(false);
const [showAudioSlider, setShowAudioSlider] = useState(false);
// Pause the controls auto-hide while the player settings popover is open so
// it can't be dismissed out from under the user (notably the iOS popover,
// which lives inside the controls and closes when they fade out). The popover
// reports its open state through ControlsContext rather than prop drilling.
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
const controlsContextValue = useMemo(() => ({ setSettingsMenuOpen }), []);
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = usePlaybackManager({
@@ -467,7 +474,7 @@ export const Controls: FC<Props> = ({
episodeView,
onHideControls: hideControls,
timeout: CONTROLS_CONSTANTS.TIMEOUT,
disabled: true,
disabled: settingsMenuOpen,
});
const switchOnEpisodeMode = useCallback(() => {
@@ -478,122 +485,124 @@ export const Controls: FC<Props> = ({
}, [isPlaying, togglePlay]);
return (
<View style={styles.controlsContainer} pointerEvents='box-none'>
{episodeView ? (
<EpisodeList
item={item}
close={() => setEpisodeView(false)}
goToItem={goToItemCommon}
/>
) : (
<>
<GestureOverlay
screenWidth={screenWidth}
screenHeight={screenHeight}
showControls={showControls}
onToggleControls={toggleControls}
onSkipForward={handleSkipForward}
onSkipBackward={handleSkipBackward}
<ControlsProvider value={controlsContextValue}>
<View style={styles.controlsContainer} pointerEvents='box-none'>
{episodeView ? (
<EpisodeList
item={item}
close={() => setEpisodeView(false)}
goToItem={goToItemCommon}
/>
{/* Technical Info Overlay - rendered outside animated views to stay visible */}
{getTechnicalInfo && (
<TechnicalInfoOverlay
) : (
<>
<GestureOverlay
screenWidth={screenWidth}
screenHeight={screenHeight}
showControls={showControls}
visible={showTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
mediaSource={mediaSource}
onToggleControls={toggleControls}
onSkipForward={handleSkipForward}
onSkipBackward={handleSkipBackward}
/>
)}
<Animated.View
style={headerAnimatedStyle}
pointerEvents={showControls ? "auto" : "none"}
>
<HeaderControls
item={item}
showControls={showControls}
offline={offline}
mediaSource={mediaSource}
startPictureInPicture={startPictureInPicture}
switchOnEpisodeMode={switchOnEpisodeMode}
goToPreviousItem={goToPreviousItem}
goToNextItem={goToNextItem}
previousItem={previousItem}
nextItem={nextItem}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={onZoomToggle}
playbackSpeed={playbackSpeed}
setPlaybackSpeed={setPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={onToggleTechnicalInfo}
/>
</Animated.View>
<Animated.View
style={centerAnimatedStyle}
pointerEvents={showControls ? "box-none" : "none"}
>
<CenterControls
showControls={showControls}
isPlaying={isPlaying}
isBuffering={isBuffering}
showAudioSlider={showAudioSlider}
setShowAudioSlider={setShowAudioSlider}
togglePlay={togglePlay}
handleSkipBackward={handleSkipBackward}
handleSkipForward={handleSkipForward}
hasChapters={hasChapters}
hasPreviousChapter={hasPreviousChapter}
hasNextChapter={hasNextChapter}
goToPreviousChapter={goToPreviousChapter}
goToNextChapter={goToNextChapter}
/>
</Animated.View>
<Animated.View
style={bottomAnimatedStyle}
pointerEvents={showControls ? "auto" : "none"}
>
<BottomControls
item={item}
chapters={item.Chapters}
durationMs={maxMs}
showControls={showControls}
isSliding={isSliding}
showRemoteBubble={showRemoteBubble}
currentTime={currentTime}
remainingTime={remainingTime}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro}
skipCredit={skipCredit}
nextItem={nextItem}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual}
handleControlsInteraction={handleControlsInteraction}
min={min}
max={max}
effectiveProgress={effectiveProgress}
cacheProgress={cacheProgress}
handleSliderStart={handleSliderStart}
handleSliderComplete={handleSliderComplete}
handleSliderChange={handleSliderChange}
handleTouchStart={handleTouchStart}
handleTouchEnd={handleTouchEnd}
seekTo={seekTo}
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}
chapterPositions={chapterPositions}
/>
</Animated.View>
</>
)}
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
)}
</View>
{/* Technical Info Overlay - rendered outside animated views to stay visible */}
{getTechnicalInfo && (
<TechnicalInfoOverlay
showControls={showControls}
visible={showTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
mediaSource={mediaSource}
/>
)}
<Animated.View
style={headerAnimatedStyle}
pointerEvents={showControls ? "auto" : "none"}
>
<HeaderControls
item={item}
showControls={showControls}
offline={offline}
mediaSource={mediaSource}
startPictureInPicture={startPictureInPicture}
switchOnEpisodeMode={switchOnEpisodeMode}
goToPreviousItem={goToPreviousItem}
goToNextItem={goToNextItem}
previousItem={previousItem}
nextItem={nextItem}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={onZoomToggle}
playbackSpeed={playbackSpeed}
setPlaybackSpeed={setPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={onToggleTechnicalInfo}
/>
</Animated.View>
<Animated.View
style={centerAnimatedStyle}
pointerEvents={showControls ? "box-none" : "none"}
>
<CenterControls
showControls={showControls}
isPlaying={isPlaying}
isBuffering={isBuffering}
showAudioSlider={showAudioSlider}
setShowAudioSlider={setShowAudioSlider}
togglePlay={togglePlay}
handleSkipBackward={handleSkipBackward}
handleSkipForward={handleSkipForward}
hasChapters={hasChapters}
hasPreviousChapter={hasPreviousChapter}
hasNextChapter={hasNextChapter}
goToPreviousChapter={goToPreviousChapter}
goToNextChapter={goToNextChapter}
/>
</Animated.View>
<Animated.View
style={bottomAnimatedStyle}
pointerEvents={showControls ? "auto" : "none"}
>
<BottomControls
item={item}
chapters={item.Chapters}
durationMs={maxMs}
showControls={showControls}
isSliding={isSliding}
showRemoteBubble={showRemoteBubble}
currentTime={currentTime}
remainingTime={remainingTime}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro}
skipCredit={skipCredit}
nextItem={nextItem}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual}
handleControlsInteraction={handleControlsInteraction}
min={min}
max={max}
effectiveProgress={effectiveProgress}
cacheProgress={cacheProgress}
handleSliderStart={handleSliderStart}
handleSliderComplete={handleSliderComplete}
handleSliderChange={handleSliderChange}
handleTouchStart={handleTouchStart}
handleTouchEnd={handleTouchEnd}
seekTo={seekTo}
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}
chapterPositions={chapterPositions}
/>
</Animated.View>
</>
)}
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
)}
</View>
</ControlsProvider>
);
};

View File

@@ -0,0 +1,26 @@
import { createContext, useContext } from "react";
interface ControlsContextProps {
/**
* Lets descendants (e.g. the settings popover) pause the controls auto-hide
* while an interactive menu is open, so it can't be dismissed out from under
* the user. Mirrors the `settingsMenuOpen` state owned by `Controls`.
*/
setSettingsMenuOpen: (open: boolean) => void;
}
const ControlsContext = createContext<ControlsContextProps | undefined>(
undefined,
);
export const ControlsProvider = ControlsContext.Provider;
export const useControlsContext = () => {
const ctx = useContext(ControlsContext);
if (!ctx) {
throw new Error(
"useControlsContext must be used within a ControlsProvider",
);
}
return ctx;
};

View File

@@ -7,6 +7,7 @@ import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import useRouter from "@/hooks/useAppRouter";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { useControlsContext } from "../contexts/ControlsContext";
import { usePlayerContext } from "../contexts/PlayerContext";
import { useVideoContext } from "../contexts/VideoContext";
import { PlaybackSpeedScope } from "../utils/playback-speed-settings";
@@ -30,6 +31,8 @@ const DropdownView = ({
}: DropdownViewProps) => {
const { subtitleTracks, audioTracks } = useVideoContext();
const { item, mediaSource } = usePlayerContext();
// Report popover open/close so Controls can pause auto-hide while it's open.
const { setSettingsMenuOpen } = useControlsContext();
const { settings, updateSettings } = useSettings();
const router = useRouter();
const isOffline = useOfflineMode();
@@ -223,6 +226,7 @@ const DropdownView = ({
groups={optionGroups}
trigger={trigger}
expoUIConfig={{}}
onOpenChange={setSettingsMenuOpen}
bottomSheetConfig={{
enablePanDownToClose: true,
}}