mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-16 19:00:28 +01:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user