mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-17 22:42:03 +01:00
fix(tv): modals
This commit is contained in:
@@ -11,13 +11,10 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
BackHandler,
|
||||
Dimensions,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
TVFocusGuideView,
|
||||
useTVEventHandler,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -26,19 +23,16 @@ import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import type { TVOptionItem } from "@/components/tv";
|
||||
import {
|
||||
TVButton,
|
||||
TVOptionSelector,
|
||||
useTVFocusAnimation,
|
||||
} from "@/components/tv";
|
||||
import { TVSubtitleSheet } from "@/components/video-player/controls/TVSubtitleSheet";
|
||||
import { TVButton, useTVFocusAnimation } from "@/components/tv";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
@@ -385,10 +379,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||
};
|
||||
|
||||
// Modal state for option selectors
|
||||
type ModalType = "audio" | "subtitle" | "mediaSource" | "quality" | null;
|
||||
const [openModal, setOpenModal] = useState<ModalType>(null);
|
||||
const isModalOpen = openModal !== null;
|
||||
// TV Option Modal hook for quality, audio, media source selectors
|
||||
const { showOptions } = useTVOptionModal();
|
||||
|
||||
// TV Subtitle Modal hook
|
||||
const { showSubtitleModal } = useTVSubtitleModal();
|
||||
|
||||
// State for first actor card ref (used for focus guide)
|
||||
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
|
||||
@@ -400,28 +395,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
null,
|
||||
);
|
||||
|
||||
// Android TV BackHandler for closing modals
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && isModalOpen) {
|
||||
const backHandler = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
() => {
|
||||
setOpenModal(null);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
return () => backHandler.remove();
|
||||
}
|
||||
}, [isModalOpen]);
|
||||
|
||||
// tvOS menu button handler for closing modals
|
||||
useTVEventHandler((evt) => {
|
||||
if (!evt || !isModalOpen) return;
|
||||
if (evt.eventType === "menu" || evt.eventType === "back") {
|
||||
setOpenModal(null);
|
||||
}
|
||||
});
|
||||
|
||||
// Get available audio tracks
|
||||
const audioTracks = useMemo(() => {
|
||||
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||
@@ -883,7 +856,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
}
|
||||
label={t("item_card.quality")}
|
||||
value={selectedQualityLabel}
|
||||
onPress={() => setOpenModal("quality")}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("item_card.quality"),
|
||||
options: qualityOptions,
|
||||
onSelect: handleQualityChange,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Media source selector (only if multiple sources) */}
|
||||
@@ -896,7 +875,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
}
|
||||
label={t("item_card.video")}
|
||||
value={selectedMediaSourceLabel}
|
||||
onPress={() => setOpenModal("mediaSource")}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("item_card.video"),
|
||||
options: mediaSourceOptions,
|
||||
onSelect: handleMediaSourceChange,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -910,7 +895,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
}
|
||||
label={t("item_card.audio")}
|
||||
value={selectedAudioLabel}
|
||||
onPress={() => setOpenModal("audio")}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("item_card.audio"),
|
||||
options: audioOptions,
|
||||
onSelect: handleAudioChange,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -925,7 +916,18 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
}
|
||||
label={t("item_card.subtitles.label")}
|
||||
value={selectedSubtitleLabel}
|
||||
onPress={() => setOpenModal("subtitle")}
|
||||
onPress={() =>
|
||||
showSubtitleModal({
|
||||
item,
|
||||
mediaSourceId: selectedOptions?.mediaSource?.Id,
|
||||
subtitleTracks,
|
||||
currentSubtitleIndex:
|
||||
selectedOptions?.subtitleIndex ?? -1,
|
||||
onSubtitleIndexChange: handleSubtitleChange,
|
||||
onServerSubtitleDownloaded:
|
||||
handleServerSubtitleDownloaded,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -1204,45 +1206,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Option selector modals */}
|
||||
<TVOptionSelector
|
||||
visible={openModal === "quality"}
|
||||
title={t("item_card.quality")}
|
||||
options={qualityOptions}
|
||||
onSelect={handleQualityChange}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
|
||||
<TVOptionSelector
|
||||
visible={openModal === "mediaSource"}
|
||||
title={t("item_card.video")}
|
||||
options={mediaSourceOptions}
|
||||
onSelect={handleMediaSourceChange}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
|
||||
<TVOptionSelector
|
||||
visible={openModal === "audio"}
|
||||
title={t("item_card.audio")}
|
||||
options={audioOptions}
|
||||
onSelect={handleAudioChange}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
|
||||
{/* Unified Subtitle Sheet (tracks + download) */}
|
||||
{item && (
|
||||
<TVSubtitleSheet
|
||||
visible={openModal === "subtitle"}
|
||||
item={item}
|
||||
mediaSourceId={selectedOptions?.mediaSource?.Id}
|
||||
subtitleTracks={subtitleTracks}
|
||||
currentSubtitleIndex={selectedOptions?.subtitleIndex ?? -1}
|
||||
onSubtitleIndexChange={handleSubtitleChange}
|
||||
onClose={() => setOpenModal(null)}
|
||||
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -17,9 +17,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BackHandler,
|
||||
Image,
|
||||
Platform,
|
||||
Pressable,
|
||||
Animated as RNAnimated,
|
||||
StyleSheet,
|
||||
@@ -37,13 +35,15 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import type { TVOptionItem } from "@/components/tv";
|
||||
import { TVOptionSelector, useTVFocusAnimation } from "@/components/tv";
|
||||
import { useTVFocusAnimation } from "@/components/tv";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
||||
@@ -51,7 +51,6 @@ import { CONTROLS_CONSTANTS } from "./constants";
|
||||
import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||
import { useVideoTime } from "./hooks/useVideoTime";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
import { TVSubtitleSheet } from "./TVSubtitleSheet";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
|
||||
interface Props {
|
||||
@@ -337,24 +336,15 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const nextItem = nextItemProp ?? internalNextItem;
|
||||
|
||||
type ModalType = "audio" | "subtitle" | null;
|
||||
const [openModal, setOpenModal] = useState<ModalType>(null);
|
||||
const isModalOpen = openModal !== null;
|
||||
// TV Option Modal hook for audio selector
|
||||
const { showOptions } = useTVOptionModal();
|
||||
|
||||
const [lastOpenedModal, setLastOpenedModal] = useState<ModalType>(null);
|
||||
// TV Subtitle Modal hook
|
||||
const { showSubtitleModal } = useTVSubtitleModal();
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && isModalOpen) {
|
||||
const backHandler = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
() => {
|
||||
setOpenModal(null);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
return () => backHandler.remove();
|
||||
}
|
||||
}, [isModalOpen]);
|
||||
// Track which button should have preferred focus when controls show
|
||||
type LastModalType = "audio" | "subtitle" | null;
|
||||
const [lastOpenedModal, setLastOpenedModal] = useState<LastModalType>(null);
|
||||
|
||||
const audioTracks = useMemo(() => {
|
||||
return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
|
||||
@@ -474,10 +464,8 @@ export const Controls: FC<Props> = ({
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (isModalOpen) {
|
||||
setOpenModal(null);
|
||||
}
|
||||
}, [isModalOpen]);
|
||||
// No longer needed since modals are screen-based
|
||||
}, []);
|
||||
|
||||
const { isSliding: isRemoteSliding } = useRemoteControl({
|
||||
showControls,
|
||||
@@ -488,15 +476,13 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const handleOpenAudioSheet = useCallback(() => {
|
||||
setLastOpenedModal("audio");
|
||||
setOpenModal("audio");
|
||||
showOptions({
|
||||
title: t("item_card.audio"),
|
||||
options: audioOptions,
|
||||
onSelect: handleAudioChange,
|
||||
});
|
||||
controlsInteractionRef.current();
|
||||
}, []);
|
||||
|
||||
const handleOpenSubtitleSheet = useCallback(() => {
|
||||
setLastOpenedModal("subtitle");
|
||||
setOpenModal("subtitle");
|
||||
controlsInteractionRef.current();
|
||||
}, []);
|
||||
}, [showOptions, t, audioOptions, handleAudioChange]);
|
||||
|
||||
const handleServerSubtitleDownloaded = useCallback(() => {
|
||||
onServerSubtitleDownloaded?.();
|
||||
@@ -509,6 +495,29 @@ export const Controls: FC<Props> = ({
|
||||
[addSubtitleFile],
|
||||
);
|
||||
|
||||
const handleOpenSubtitleSheet = useCallback(() => {
|
||||
setLastOpenedModal("subtitle");
|
||||
showSubtitleModal({
|
||||
item,
|
||||
mediaSourceId: mediaSource?.Id,
|
||||
subtitleTracks,
|
||||
currentSubtitleIndex: subtitleIndex ?? -1,
|
||||
onSubtitleIndexChange: handleSubtitleChange,
|
||||
onServerSubtitleDownloaded: handleServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded,
|
||||
});
|
||||
controlsInteractionRef.current();
|
||||
}, [
|
||||
showSubtitleModal,
|
||||
item,
|
||||
mediaSource?.Id,
|
||||
subtitleTracks,
|
||||
subtitleIndex,
|
||||
handleSubtitleChange,
|
||||
handleServerSubtitleDownloaded,
|
||||
handleLocalSubtitleDownloaded,
|
||||
]);
|
||||
|
||||
const effectiveProgress = useSharedValue(0);
|
||||
|
||||
const SEEK_THRESHOLD_MS = 5000;
|
||||
@@ -759,7 +768,7 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
<Animated.View
|
||||
style={[styles.bottomContainer, bottomAnimatedStyle]}
|
||||
pointerEvents={showControls && !isModalOpen ? "auto" : "none"}
|
||||
pointerEvents={showControls && !false ? "auto" : "none"}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
@@ -788,7 +797,7 @@ export const Controls: FC<Props> = ({
|
||||
<TVControlButton
|
||||
icon='play-skip-back'
|
||||
onPress={handlePreviousItem}
|
||||
disabled={isModalOpen || !previousItem}
|
||||
disabled={false || !previousItem}
|
||||
size={28}
|
||||
/>
|
||||
<TVControlButton
|
||||
@@ -796,14 +805,14 @@ export const Controls: FC<Props> = ({
|
||||
onPress={handleSeekBackwardButton}
|
||||
onLongPress={startContinuousSeekBackward}
|
||||
onPressOut={stopContinuousSeeking}
|
||||
disabled={isModalOpen}
|
||||
disabled={false}
|
||||
size={28}
|
||||
/>
|
||||
<TVControlButton
|
||||
icon={isPlaying ? "pause" : "play"}
|
||||
onPress={handlePlayPauseButton}
|
||||
disabled={isModalOpen}
|
||||
hasTVPreferredFocus={!isModalOpen && lastOpenedModal === null}
|
||||
disabled={false}
|
||||
hasTVPreferredFocus={!false && lastOpenedModal === null}
|
||||
size={36}
|
||||
/>
|
||||
<TVControlButton
|
||||
@@ -811,13 +820,13 @@ export const Controls: FC<Props> = ({
|
||||
onPress={handleSeekForwardButton}
|
||||
onLongPress={startContinuousSeekForward}
|
||||
onPressOut={stopContinuousSeeking}
|
||||
disabled={isModalOpen}
|
||||
disabled={false}
|
||||
size={28}
|
||||
/>
|
||||
<TVControlButton
|
||||
icon='play-skip-forward'
|
||||
onPress={handleNextItemButton}
|
||||
disabled={isModalOpen || !nextItem}
|
||||
disabled={false || !nextItem}
|
||||
size={28}
|
||||
/>
|
||||
|
||||
@@ -827,10 +836,8 @@ export const Controls: FC<Props> = ({
|
||||
<TVControlButton
|
||||
icon='volume-high'
|
||||
onPress={handleOpenAudioSheet}
|
||||
disabled={isModalOpen}
|
||||
hasTVPreferredFocus={
|
||||
!isModalOpen && lastOpenedModal === "audio"
|
||||
}
|
||||
disabled={false}
|
||||
hasTVPreferredFocus={!false && lastOpenedModal === "audio"}
|
||||
size={24}
|
||||
/>
|
||||
)}
|
||||
@@ -838,10 +845,8 @@ export const Controls: FC<Props> = ({
|
||||
<TVControlButton
|
||||
icon='text'
|
||||
onPress={handleOpenSubtitleSheet}
|
||||
disabled={isModalOpen}
|
||||
hasTVPreferredFocus={
|
||||
!isModalOpen && lastOpenedModal === "subtitle"
|
||||
}
|
||||
disabled={false}
|
||||
hasTVPreferredFocus={!false && lastOpenedModal === "subtitle"}
|
||||
size={24}
|
||||
/>
|
||||
</View>
|
||||
@@ -892,26 +897,6 @@ export const Controls: FC<Props> = ({
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<TVOptionSelector
|
||||
visible={openModal === "audio"}
|
||||
title={t("item_card.audio")}
|
||||
options={audioOptions}
|
||||
onSelect={handleAudioChange}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
|
||||
<TVSubtitleSheet
|
||||
visible={openModal === "subtitle"}
|
||||
item={item}
|
||||
mediaSourceId={mediaSource?.Id}
|
||||
subtitleTracks={subtitleTracks}
|
||||
currentSubtitleIndex={subtitleIndex ?? -1}
|
||||
onSubtitleIndexChange={handleSubtitleChange}
|
||||
onClose={() => setOpenModal(null)}
|
||||
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
|
||||
onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -381,6 +381,13 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
onLocalSubtitleDownloaded,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
console.log(
|
||||
"[TVSubtitleSheet] visible:",
|
||||
visible,
|
||||
"tracks:",
|
||||
subtitleTracks.length,
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<TabType>("tracks");
|
||||
const [selectedLanguage, setSelectedLanguage] = useState("eng");
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
Reference in New Issue
Block a user