mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 20:18:29 +01:00
Merge origin/develop into refactor-chromecast
Bring 323 commits of develop (incl. the Expo SDK 56 / TV-branch work) into the chromecast refactor. Conflict resolutions: - chapters: take develop's reviewed version (ChapterList/ChapterTicks/ chapters.ts/test) — adds chapterNameAt, markers API, themed Colors. - auto-skip: keep chromecast's unified useSegmentSkipper for the phone player; restore develop's useCreditSkipper/useIntroSkipper (deleted on chromecast) so develop's Controls.tv.tsx compiles. TV->useSegmentSkipper migration left as follow-up. - en.json: union the two player blocks (kept chromecast casting keys + develop's subtitle/playback keys). - TechnicalInfoOverlay/PlatformDropdown: take develop's TV-safe versions (kept chromecast's disabled-prop branch, aliased to avoid shadowing the @expo/ui disabled modifier). - SDK 56 fixes: expo-router Router -> ImperativeRouter in cast components; ChapterTicks markers API in CastPlayerProgressBar. - restore utils/profiles/chromecast* (deleted on chromecast, still used by PlayButton). Typecheck passes; bun.lock regenerated against merged package.json.
This commit is contained in:
@@ -105,14 +105,14 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
||||
maximumValue={max}
|
||||
thumbWidth={0}
|
||||
onValueChange={handleValueChange}
|
||||
renderBubble={() => null}
|
||||
renderThumb={() => null}
|
||||
containerStyle={{
|
||||
borderRadius: 50,
|
||||
}}
|
||||
theme={{
|
||||
minimumTrackTintColor: "#FDFDFD",
|
||||
maximumTrackTintColor: "#5A5A5A",
|
||||
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
||||
bubbleTextColor: "transparent", // Hide the value text
|
||||
}}
|
||||
/>
|
||||
<Ionicons
|
||||
|
||||
@@ -15,12 +15,16 @@ import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { chapterMarkers } from "@/utils/chapters";
|
||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { TimeDisplay } from "./TimeDisplay";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
|
||||
// Chapter tick height in dp — matches the slider track height for a clean,
|
||||
// flush look (no top/bottom overflow).
|
||||
const TICK_HEIGHT = 10;
|
||||
|
||||
interface BottomControlsProps {
|
||||
item: BaseItemDto;
|
||||
/** Item chapters, used for the tick overlay and chapter list. */
|
||||
@@ -55,6 +59,8 @@ interface BottomControlsProps {
|
||||
handleSliderChange: (value: number) => void;
|
||||
handleTouchStart: () => void;
|
||||
handleTouchEnd: () => void;
|
||||
/** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */
|
||||
seekTo: (value: number) => void;
|
||||
|
||||
// Trickplay props
|
||||
trickPlayUrl: {
|
||||
@@ -74,6 +80,9 @@ interface BottomControlsProps {
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
// Chapter props
|
||||
chapterPositions?: number[];
|
||||
}
|
||||
|
||||
export const BottomControls: FC<BottomControlsProps> = ({
|
||||
@@ -106,9 +115,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
handleSliderChange,
|
||||
handleTouchStart,
|
||||
handleTouchEnd,
|
||||
seekTo,
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
chapterPositions = [],
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
@@ -199,6 +210,22 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
[api, nextItem],
|
||||
);
|
||||
|
||||
// Current chapter name for the always-visible header label (live playback).
|
||||
const currentChapterName = useMemo(
|
||||
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
|
||||
[hasChapters, currentTime, chapters],
|
||||
);
|
||||
|
||||
// Chapter name at the scrubbed position for the trickplay bubble. `time` is
|
||||
// an {h,m,s} object derived from the slider's dragged value — convert back
|
||||
// to ms for the lookup. Only useful while actively scrubbing.
|
||||
const scrubChapterName = useMemo(() => {
|
||||
if (!hasChapters) return null;
|
||||
const scrubMs =
|
||||
(time.hours * 3600 + time.minutes * 60 + time.seconds) * 1000;
|
||||
return chapterNameAt(scrubMs, chapters);
|
||||
}, [hasChapters, time.hours, time.minutes, time.seconds, chapters]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -217,7 +244,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
onTouchStart={handleControlsInteraction}
|
||||
>
|
||||
<View
|
||||
className='shrink flex flex-col justify-center h-full'
|
||||
className='shrink flex flex-col justify-center'
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
@@ -239,6 +266,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
{item?.Type === "Audio" && (
|
||||
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
||||
)}
|
||||
{currentChapterName ? (
|
||||
<Text className='text-xs opacity-70 mt-1' numberOfLines={1}>
|
||||
{currentChapterName}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||
{hasChapters && (
|
||||
@@ -288,6 +320,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
height: 10,
|
||||
justifyContent: "center",
|
||||
alignItems: "stretch",
|
||||
// Allow chapter ticks taller than the 10px track to bleed out
|
||||
// top/bottom (RN defaults to overflow: "hidden" on Android).
|
||||
overflow: "visible",
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
@@ -315,6 +350,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={time}
|
||||
chapterName={scrubChapterName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -324,7 +360,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
minimumValue={min}
|
||||
maximumValue={max}
|
||||
/>
|
||||
<ChapterTicks chapters={chapters} durationMs={durationMs} />
|
||||
<ChapterTicks markers={chapterMarkerList} height={TICK_HEIGHT} />
|
||||
</View>
|
||||
<TimeDisplay
|
||||
currentTime={currentTime}
|
||||
@@ -336,7 +372,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
visible={chapterListVisible}
|
||||
chapters={chapters}
|
||||
currentPositionMs={currentTime}
|
||||
onSeek={(ms) => handleSliderComplete(ms)}
|
||||
onSeek={seekTo}
|
||||
onClose={() => setChapterListVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -88,14 +88,14 @@ const BrightnessSlider = () => {
|
||||
maximumValue={max}
|
||||
thumbWidth={0}
|
||||
onValueChange={handleValueChange}
|
||||
renderBubble={() => null}
|
||||
renderThumb={() => null}
|
||||
containerStyle={{
|
||||
borderRadius: 50,
|
||||
}}
|
||||
theme={{
|
||||
minimumTrackTintColor: "#FDFDFD",
|
||||
maximumTrackTintColor: "#5A5A5A",
|
||||
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
||||
bubbleTextColor: "transparent", // Hide the value text
|
||||
}}
|
||||
/>
|
||||
<Ionicons
|
||||
|
||||
@@ -18,6 +18,12 @@ interface CenterControlsProps {
|
||||
togglePlay: () => void;
|
||||
handleSkipBackward: () => void;
|
||||
handleSkipForward: () => void;
|
||||
// Chapter navigation props
|
||||
hasChapters?: boolean;
|
||||
hasPreviousChapter?: boolean;
|
||||
hasNextChapter?: boolean;
|
||||
goToPreviousChapter?: () => void;
|
||||
goToNextChapter?: () => void;
|
||||
}
|
||||
|
||||
export const CenterControls: FC<CenterControlsProps> = ({
|
||||
@@ -29,6 +35,11 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
togglePlay,
|
||||
handleSkipBackward,
|
||||
handleSkipForward,
|
||||
hasChapters = false,
|
||||
hasPreviousChapter = false,
|
||||
hasNextChapter = false,
|
||||
goToPreviousChapter,
|
||||
goToNextChapter,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -44,7 +55,7 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
transform: [{ translateY: -22.5 }],
|
||||
paddingHorizontal: "28%",
|
||||
paddingHorizontal: hasChapters ? "18%" : "28%",
|
||||
}}
|
||||
pointerEvents={showControls ? "box-none" : "none"}
|
||||
>
|
||||
@@ -94,6 +105,20 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{!Platform.isTV && hasChapters && (
|
||||
<TouchableOpacity
|
||||
onPress={goToPreviousChapter}
|
||||
disabled={!hasPreviousChapter}
|
||||
style={{ opacity: hasPreviousChapter ? 1 : 0.3 }}
|
||||
>
|
||||
<Ionicons
|
||||
name='play-back'
|
||||
size={ICON_SIZES.CENTER - 10}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
||||
<TouchableOpacity onPress={togglePlay}>
|
||||
{!isBuffering ? (
|
||||
@@ -108,6 +133,20 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{!Platform.isTV && hasChapters && (
|
||||
<TouchableOpacity
|
||||
onPress={goToNextChapter}
|
||||
disabled={!hasNextChapter}
|
||||
style={{ opacity: hasNextChapter ? 1 : 0.3 }}
|
||||
>
|
||||
<Ionicons
|
||||
name='play-forward'
|
||||
size={ICON_SIZES.CENTER - 10}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{!Platform.isTV && (
|
||||
<TouchableOpacity onPress={handleSkipForward}>
|
||||
<View
|
||||
|
||||
65
components/video-player/controls/ChapterMarkers.tsx
Normal file
65
components/video-player/controls/ChapterMarkers.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { StyleSheet, View, type ViewStyle } from "react-native";
|
||||
|
||||
export interface ChapterMarkersProps {
|
||||
/** Array of chapter positions as percentages (0-100) */
|
||||
chapterPositions: number[];
|
||||
/** Optional style overrides for the container */
|
||||
style?: ViewStyle;
|
||||
/** Height of the marker lines (should be > track height to extend above) */
|
||||
markerHeight?: number;
|
||||
/** Color of the marker lines */
|
||||
markerColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders vertical tick marks on the progress bar at chapter positions
|
||||
* Should be overlaid on the slider track
|
||||
*/
|
||||
export const ChapterMarkers: React.FC<ChapterMarkersProps> = React.memo(
|
||||
({
|
||||
chapterPositions,
|
||||
style,
|
||||
markerHeight = 15,
|
||||
markerColor = "rgba(255, 255, 255, 0.6)",
|
||||
}) => {
|
||||
if (!chapterPositions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]} pointerEvents='none'>
|
||||
{chapterPositions.map((position, index) => (
|
||||
<View
|
||||
key={`chapter-marker-${index}`}
|
||||
style={[
|
||||
styles.marker,
|
||||
{
|
||||
left: `${position}%`,
|
||||
height: markerHeight,
|
||||
bottom: 0,
|
||||
backgroundColor: markerColor,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
marker: {
|
||||
position: "absolute",
|
||||
width: 2,
|
||||
borderRadius: 1,
|
||||
transform: [{ translateX: -1 }], // Center the marker on its position
|
||||
},
|
||||
});
|
||||
@@ -41,6 +41,7 @@ import { CONTROLS_CONSTANTS } from "./constants";
|
||||
import { EpisodeList } from "./EpisodeList";
|
||||
import { GestureOverlay } from "./GestureOverlay";
|
||||
import { HeaderControls } from "./HeaderControls";
|
||||
import { useChapterNavigation } from "./hooks/useChapterNavigation";
|
||||
import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||
import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
||||
import { useVideoSlider } from "./hooks/useVideoSlider";
|
||||
@@ -240,6 +241,21 @@ export const Controls: FC<Props> = ({
|
||||
isSeeking,
|
||||
});
|
||||
|
||||
// Chapter navigation hook
|
||||
const {
|
||||
hasChapters,
|
||||
hasPreviousChapter,
|
||||
hasNextChapter,
|
||||
goToPreviousChapter,
|
||||
goToNextChapter,
|
||||
chapterPositions,
|
||||
} = useChapterNavigation({
|
||||
chapters: item.Chapters,
|
||||
progress,
|
||||
maxMs,
|
||||
seek,
|
||||
});
|
||||
|
||||
const toggleControls = useCallback(() => {
|
||||
if (showControls) {
|
||||
setShowAudioSlider(false);
|
||||
@@ -280,6 +296,7 @@ export const Controls: FC<Props> = ({
|
||||
handleTouchEnd,
|
||||
handleSliderComplete,
|
||||
handleSliderChange,
|
||||
seekTo,
|
||||
} = useVideoSlider({
|
||||
progress,
|
||||
isSeeking,
|
||||
@@ -466,10 +483,15 @@ export const Controls: FC<Props> = ({
|
||||
mediaSource: newMediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(item, settings, {
|
||||
indexes: previousIndexes,
|
||||
source: mediaSource ?? undefined,
|
||||
});
|
||||
} = getDefaultPlaySettings(
|
||||
item,
|
||||
settings,
|
||||
{
|
||||
indexes: previousIndexes,
|
||||
source: mediaSource ?? undefined,
|
||||
},
|
||||
{ applyLanguagePreferences: true },
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
...(offline && { offline: "true" }),
|
||||
@@ -608,6 +630,7 @@ export const Controls: FC<Props> = ({
|
||||
getTechnicalInfo={getTechnicalInfo}
|
||||
playMethod={playMethod}
|
||||
transcodeReasons={transcodeReasons}
|
||||
mediaSource={mediaSource}
|
||||
/>
|
||||
)}
|
||||
<Animated.View
|
||||
@@ -647,6 +670,11 @@ export const Controls: FC<Props> = ({
|
||||
togglePlay={togglePlay}
|
||||
handleSkipBackward={handleSkipBackward}
|
||||
handleSkipForward={handleSkipForward}
|
||||
hasChapters={hasChapters}
|
||||
hasPreviousChapter={hasPreviousChapter}
|
||||
hasNextChapter={hasNextChapter}
|
||||
goToPreviousChapter={goToPreviousChapter}
|
||||
goToNextChapter={goToNextChapter}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
@@ -683,9 +711,11 @@ export const Controls: FC<Props> = ({
|
||||
handleSliderChange={handleSliderChange}
|
||||
handleTouchStart={handleTouchStart}
|
||||
handleTouchEnd={handleTouchEnd}
|
||||
seekTo={seekTo}
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
chapterPositions={chapterPositions}
|
||||
/>
|
||||
</Animated.View>
|
||||
</>
|
||||
|
||||
1623
components/video-player/controls/Controls.tv.tsx
Normal file
1623
components/video-player/controls/Controls.tv.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ export const GestureOverlay = ({
|
||||
});
|
||||
const [fadeAnim] = useState(new Animated.Value(0));
|
||||
const isDraggingRef = useRef(false);
|
||||
const hideScheduledRef = useRef(false);
|
||||
const hideTimeoutRef = useRef<number | null>(null);
|
||||
const lastUpdateTime = useRef(0);
|
||||
|
||||
@@ -51,18 +52,11 @@ export const GestureOverlay = ({
|
||||
side?: "left" | "right",
|
||||
isDuringDrag = false,
|
||||
) => {
|
||||
// Clear any existing timeout
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current);
|
||||
hideTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Defer ALL state updates to avoid useInsertionEffect warning
|
||||
requestAnimationFrame(() => {
|
||||
setFeedback({ visible: true, icon, text, side });
|
||||
|
||||
if (!isDuringDrag) {
|
||||
// For discrete actions (like skip), show normal animation
|
||||
hideScheduledRef.current = false;
|
||||
Animated.sequence([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
@@ -80,16 +74,17 @@ export const GestureOverlay = ({
|
||||
setFeedback((prev) => ({ ...prev, visible: false }));
|
||||
});
|
||||
});
|
||||
} else if (!isDraggingRef.current) {
|
||||
// For drag start, just fade in and stay visible
|
||||
} else if (!isDraggingRef.current && !hideScheduledRef.current) {
|
||||
// Cancel any pending hide from a previous drag
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current);
|
||||
hideTimeoutRef.current = null;
|
||||
}
|
||||
hideScheduledRef.current = false;
|
||||
isDraggingRef.current = true;
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
fadeAnim.stopAnimation();
|
||||
fadeAnim.setValue(1);
|
||||
}
|
||||
// For drag updates, just update the state, don't restart animation
|
||||
});
|
||||
},
|
||||
[fadeAnim],
|
||||
@@ -97,9 +92,9 @@ export const GestureOverlay = ({
|
||||
|
||||
const hideDragFeedback = useCallback(() => {
|
||||
isDraggingRef.current = false;
|
||||
|
||||
// Delay hiding slightly to avoid flicker
|
||||
hideScheduledRef.current = true;
|
||||
hideTimeoutRef.current = setTimeout(() => {
|
||||
fadeAnim.stopAnimation();
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
@@ -107,6 +102,7 @@ export const GestureOverlay = ({
|
||||
}).start(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setFeedback((prev) => ({ ...prev, visible: false }));
|
||||
hideScheduledRef.current = false;
|
||||
});
|
||||
});
|
||||
}, 100) as unknown as number;
|
||||
|
||||
@@ -123,7 +123,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
</View>
|
||||
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV && (
|
||||
{/* Rotate toggle is Android-only: iOS does not reliably rotate the
|
||||
player back to portrait programmatically. */}
|
||||
{Platform.OS === "android" && (
|
||||
<TouchableOpacity
|
||||
onPress={toggleOrientation}
|
||||
disabled={isTogglingOrientation}
|
||||
|
||||
560
components/video-player/controls/TVSubtitleSheet.tsx
Normal file
560
components/video-player/controls/TVSubtitleSheet.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Easing,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
TVCancelButton,
|
||||
TVLanguageCard,
|
||||
TVSubtitleResultCard,
|
||||
TVTabButton,
|
||||
TVTrackCard,
|
||||
} from "@/components/tv";
|
||||
import {
|
||||
type SubtitleSearchResult,
|
||||
useRemoteSubtitles,
|
||||
} from "@/hooks/useRemoteSubtitles";
|
||||
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
||||
|
||||
interface TVSubtitleSheetProps {
|
||||
visible: boolean;
|
||||
item: BaseItemDto;
|
||||
mediaSourceId?: string | null;
|
||||
subtitleTracks: MediaStream[];
|
||||
currentSubtitleIndex: number;
|
||||
onSubtitleIndexChange: (index: number) => void;
|
||||
onClose: () => void;
|
||||
onServerSubtitleDownloaded?: () => void;
|
||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||
}
|
||||
|
||||
type TabType = "tracks" | "download";
|
||||
|
||||
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
visible,
|
||||
item,
|
||||
mediaSourceId,
|
||||
subtitleTracks,
|
||||
currentSubtitleIndex,
|
||||
onSubtitleIndexChange,
|
||||
onClose,
|
||||
onServerSubtitleDownloaded,
|
||||
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);
|
||||
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isTabContentReady, setIsTabContentReady] = useState(false);
|
||||
const firstTrackRef = useRef<View>(null);
|
||||
|
||||
const {
|
||||
hasOpenSubtitlesApiKey,
|
||||
isSearching,
|
||||
searchError,
|
||||
searchResults,
|
||||
search,
|
||||
downloadAsync,
|
||||
reset,
|
||||
} = useRemoteSubtitles({
|
||||
itemId: item.Id ?? "",
|
||||
item,
|
||||
mediaSourceId,
|
||||
});
|
||||
|
||||
const resetRef = useRef(reset);
|
||||
resetRef.current = reset;
|
||||
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||
|
||||
const initialSelectedTrackIndex = useMemo(() => {
|
||||
if (currentSubtitleIndex === -1) return 0;
|
||||
const trackIdx = subtitleTracks.findIndex(
|
||||
(t) => t.Index === currentSubtitleIndex,
|
||||
);
|
||||
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
||||
}, [subtitleTracks, currentSubtitleIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(300);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setHasSearchedThisSession(false);
|
||||
setActiveTab("tracks");
|
||||
resetRef.current();
|
||||
setIsReady(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && activeTab === "download" && !hasSearchedThisSession) {
|
||||
search({ language: selectedLanguage });
|
||||
setHasSearchedThisSession(true);
|
||||
}
|
||||
}, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) {
|
||||
setIsTabContentReady(false);
|
||||
const timer = setTimeout(() => setIsTabContentReady(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsTabContentReady(false);
|
||||
}, [activeTab, isReady]);
|
||||
|
||||
const handleLanguageSelect = useCallback(
|
||||
(code: string) => {
|
||||
setSelectedLanguage(code);
|
||||
search({ language: code });
|
||||
},
|
||||
[search],
|
||||
);
|
||||
|
||||
const handleTrackSelect = useCallback(
|
||||
(index: number) => {
|
||||
onSubtitleIndexChange(index);
|
||||
onClose();
|
||||
},
|
||||
[onSubtitleIndexChange, onClose],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (result: SubtitleSearchResult) => {
|
||||
setDownloadingId(result.id);
|
||||
|
||||
try {
|
||||
const downloadResult = await downloadAsync(result);
|
||||
|
||||
if (downloadResult.type === "server") {
|
||||
onServerSubtitleDownloaded?.();
|
||||
} else if (downloadResult.type === "local" && downloadResult.path) {
|
||||
onLocalSubtitleDownloaded?.(downloadResult.path);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to download subtitle:", error);
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
downloadAsync,
|
||||
onServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded,
|
||||
onClose,
|
||||
],
|
||||
);
|
||||
|
||||
const displayLanguages = useMemo(
|
||||
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
|
||||
[],
|
||||
);
|
||||
|
||||
const trackOptions = useMemo(() => {
|
||||
const noneOption = {
|
||||
label: t("item_card.subtitles.none"),
|
||||
sublabel: undefined as string | undefined,
|
||||
value: -1,
|
||||
selected: currentSubtitleIndex === -1,
|
||||
};
|
||||
const options = subtitleTracks.map((track) => ({
|
||||
label:
|
||||
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||
sublabel: track.Codec?.toUpperCase(),
|
||||
value: track.Index!,
|
||||
selected: track.Index === currentSubtitleIndex,
|
||||
}));
|
||||
return [noneOption, ...options];
|
||||
}, [subtitleTracks, currentSubtitleIndex, t]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
{/* Header with tabs */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>
|
||||
{t("item_card.subtitles.label") || "Subtitles"}
|
||||
</Text>
|
||||
|
||||
{/* Tab bar */}
|
||||
<View style={styles.tabRow}>
|
||||
<TVTabButton
|
||||
label={t("item_card.subtitles.tracks") || "Tracks"}
|
||||
active={activeTab === "tracks"}
|
||||
onSelect={() => setActiveTab("tracks")}
|
||||
/>
|
||||
<TVTabButton
|
||||
label={t("player.download") || "Download"}
|
||||
active={activeTab === "download"}
|
||||
onSelect={() => setActiveTab("download")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tracks Tab Content */}
|
||||
{activeTab === "tracks" && isTabContentReady && (
|
||||
<View style={styles.section}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tracksScroll}
|
||||
contentContainerStyle={styles.tracksScrollContent}
|
||||
>
|
||||
{trackOptions.map((option, index) => (
|
||||
<TVTrackCard
|
||||
key={option.value}
|
||||
ref={
|
||||
index === initialSelectedTrackIndex
|
||||
? firstTrackRef
|
||||
: undefined
|
||||
}
|
||||
label={option.label}
|
||||
sublabel={option.sublabel}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedTrackIndex}
|
||||
onPress={() => handleTrackSelect(option.value)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Download Tab Content */}
|
||||
{activeTab === "download" && isTabContentReady && (
|
||||
<>
|
||||
{/* Language Selector */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{t("player.language") || "Language"}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.languageScroll}
|
||||
contentContainerStyle={styles.languageScrollContent}
|
||||
>
|
||||
{displayLanguages.map((lang, index) => (
|
||||
<TVLanguageCard
|
||||
key={lang.code}
|
||||
code={lang.code}
|
||||
name={lang.name}
|
||||
selected={selectedLanguage === lang.code}
|
||||
hasTVPreferredFocus={
|
||||
index === 0 &&
|
||||
(!searchResults || searchResults.length === 0)
|
||||
}
|
||||
onPress={() => handleLanguageSelect(lang.code)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Results Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{t("player.results") || "Results"}
|
||||
{searchResults && ` (${searchResults.length})`}
|
||||
</Text>
|
||||
|
||||
{/* Loading state */}
|
||||
{isSearching && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size='large' color='#fff' />
|
||||
<Text style={styles.loadingText}>
|
||||
{t("player.searching") || "Searching..."}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{searchError && !isSearching && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons
|
||||
name='alert-circle-outline'
|
||||
size={32}
|
||||
color='rgba(255,100,100,0.8)'
|
||||
/>
|
||||
<Text style={styles.errorText}>
|
||||
{t("player.search_failed") || "Search failed"}
|
||||
</Text>
|
||||
<Text style={styles.errorHint}>
|
||||
{!hasOpenSubtitlesApiKey
|
||||
? t("player.no_subtitle_provider") ||
|
||||
"No subtitle provider configured on server"
|
||||
: String(searchError)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{searchResults &&
|
||||
searchResults.length === 0 &&
|
||||
!isSearching &&
|
||||
!searchError && (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons
|
||||
name='document-text-outline'
|
||||
size={32}
|
||||
color='rgba(255,255,255,0.4)'
|
||||
/>
|
||||
<Text style={styles.emptyText}>
|
||||
{t("player.no_subtitles_found") ||
|
||||
"No subtitles found"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Results list */}
|
||||
{searchResults &&
|
||||
searchResults.length > 0 &&
|
||||
!isSearching && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.resultsScroll}
|
||||
contentContainerStyle={styles.resultsScrollContent}
|
||||
>
|
||||
{searchResults.map((result, index) => (
|
||||
<TVSubtitleResultCard
|
||||
key={result.id}
|
||||
result={result}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
isDownloading={downloadingId === result.id}
|
||||
onPress={() => handleDownload(result)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* API Key hint if no fallback available */}
|
||||
{!hasOpenSubtitlesApiKey && (
|
||||
<View style={styles.apiKeyHint}>
|
||||
<Ionicons
|
||||
name='information-circle-outline'
|
||||
size={16}
|
||||
color='rgba(255,255,255,0.4)'
|
||||
/>
|
||||
<Text style={styles.apiKeyHintText}>
|
||||
{t("player.add_opensubtitles_key_hint") ||
|
||||
"Add OpenSubtitles API key in settings for client-side fallback"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cancel button */}
|
||||
{isReady && (
|
||||
<View style={styles.cancelButtonContainer}>
|
||||
<TVCancelButton
|
||||
onPress={onClose}
|
||||
label={t("common.cancel") || "Cancel"}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
},
|
||||
sheetContainer: {
|
||||
maxHeight: "70%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
marginBottom: 16,
|
||||
},
|
||||
tabRow: {
|
||||
flexDirection: "row",
|
||||
gap: 24,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 48,
|
||||
},
|
||||
tracksScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
tracksScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 12,
|
||||
},
|
||||
languageScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
languageScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 10,
|
||||
},
|
||||
resultsScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
resultsScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 12,
|
||||
},
|
||||
loadingContainer: {
|
||||
paddingVertical: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
loadingText: {
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
errorContainer: {
|
||||
paddingVertical: 40,
|
||||
paddingHorizontal: 48,
|
||||
alignItems: "center",
|
||||
},
|
||||
errorText: {
|
||||
color: "rgba(255,100,100,0.9)",
|
||||
marginTop: 8,
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
},
|
||||
errorHint: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyContainer: {
|
||||
paddingVertical: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
},
|
||||
apiKeyHint: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingHorizontal: 48,
|
||||
paddingTop: 8,
|
||||
},
|
||||
apiKeyHintText: {
|
||||
color: "rgba(255,255,255,0.4)",
|
||||
fontSize: 12,
|
||||
},
|
||||
cancelButtonContainer: {
|
||||
paddingHorizontal: 48,
|
||||
paddingTop: 20,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
import { type FC, memo, useCallback, useEffect, useState } from "react";
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
type FC,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Platform, StyleSheet, Text, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -7,6 +15,7 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT } from "./constants";
|
||||
@@ -19,6 +28,9 @@ interface TechnicalInfoOverlayProps {
|
||||
getTechnicalInfo: () => Promise<TechnicalInfo>;
|
||||
playMethod?: PlayMethod;
|
||||
transcodeReasons?: string[];
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
currentSubtitleIndex?: number;
|
||||
currentAudioIndex?: number;
|
||||
}
|
||||
|
||||
const formatBitrate = (bitsPerSecond: number): string => {
|
||||
@@ -47,10 +59,51 @@ const formatCodec = (codec: string): string => {
|
||||
flac: "FLAC",
|
||||
opus: "Opus",
|
||||
mp3: "MP3",
|
||||
// Subtitle codecs
|
||||
srt: "SRT",
|
||||
subrip: "SRT",
|
||||
ass: "ASS",
|
||||
ssa: "SSA",
|
||||
webvtt: "WebVTT",
|
||||
vtt: "WebVTT",
|
||||
pgs: "PGS",
|
||||
hdmv_pgs_subtitle: "PGS",
|
||||
dvd_subtitle: "VobSub",
|
||||
dvdsub: "VobSub",
|
||||
mov_text: "MOV Text",
|
||||
cc_dec: "CC",
|
||||
eia_608: "CC",
|
||||
};
|
||||
return codecMap[codec.toLowerCase()] || codec.toUpperCase();
|
||||
};
|
||||
|
||||
const formatAudioChannels = (channels: number): string => {
|
||||
switch (channels) {
|
||||
case 1:
|
||||
return "Mono";
|
||||
case 2:
|
||||
return "Stereo";
|
||||
case 6:
|
||||
return "5.1";
|
||||
case 8:
|
||||
return "7.1";
|
||||
default:
|
||||
return `${channels}ch`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatVideoRange = (range?: string | null): string | null => {
|
||||
if (!range || range === "SDR") return null;
|
||||
const rangeMap: Record<string, string> = {
|
||||
HDR10: "HDR10",
|
||||
HDR10Plus: "HDR10+",
|
||||
HLG: "HLG",
|
||||
"Dolby Vision": "Dolby Vision",
|
||||
DolbyVision: "Dolby Vision",
|
||||
};
|
||||
return rangeMap[range] || range;
|
||||
};
|
||||
|
||||
const formatFps = (fps: number): string => {
|
||||
// Common frame rates
|
||||
if (Math.abs(fps - 23.976) < 0.01) return "23.976";
|
||||
@@ -120,13 +173,56 @@ const formatTranscodeReason = (reason: string): string => {
|
||||
};
|
||||
|
||||
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
({ visible, getTechnicalInfo, playMethod, transcodeReasons }) => {
|
||||
({
|
||||
showControls: _showControls,
|
||||
visible,
|
||||
getTechnicalInfo,
|
||||
playMethod,
|
||||
transcodeReasons,
|
||||
mediaSource,
|
||||
currentSubtitleIndex,
|
||||
currentAudioIndex,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||
|
||||
const opacity = useSharedValue(0);
|
||||
|
||||
// Extract stream info from media source
|
||||
const streamInfo = useMemo(() => {
|
||||
if (!mediaSource?.MediaStreams) return null;
|
||||
|
||||
const videoStream = mediaSource.MediaStreams.find(
|
||||
(s) => s.Type === "Video",
|
||||
);
|
||||
const audioStream = mediaSource.MediaStreams.find(
|
||||
(s) =>
|
||||
s.Type === "Audio" &&
|
||||
(currentAudioIndex !== undefined
|
||||
? s.Index === currentAudioIndex
|
||||
: s.IsDefault),
|
||||
);
|
||||
const subtitleStream = mediaSource.MediaStreams.find(
|
||||
(s) =>
|
||||
s.Type === "Subtitle" &&
|
||||
currentSubtitleIndex !== undefined &&
|
||||
currentSubtitleIndex >= 0 &&
|
||||
s.Index === currentSubtitleIndex,
|
||||
);
|
||||
|
||||
return {
|
||||
container: mediaSource.Container,
|
||||
videoRange: videoStream?.VideoRangeType,
|
||||
bitDepth: videoStream?.BitDepth,
|
||||
audioChannels: audioStream?.Channels,
|
||||
audioCodecFromSource: audioStream?.Codec,
|
||||
subtitleCodec: subtitleStream?.Codec,
|
||||
subtitleTitle: subtitleStream?.DisplayTitle,
|
||||
};
|
||||
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
|
||||
|
||||
// Animate visibility based on visible prop only (stays visible regardless of controls)
|
||||
useEffect(() => {
|
||||
opacity.value = withTiming(visible ? 1 : 0, {
|
||||
@@ -162,64 +258,85 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
// Don't render if not visible
|
||||
if (!visible) return null;
|
||||
|
||||
// TV-specific styles
|
||||
const containerStyle = Platform.isTV
|
||||
? {
|
||||
top: Math.max(insets.top, 48) + 20,
|
||||
left: Math.max(insets.left, 48) + 20,
|
||||
}
|
||||
: {
|
||||
top:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||
left:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||
};
|
||||
|
||||
const textStyle = Platform.isTV
|
||||
? [
|
||||
styles.infoTextTV,
|
||||
{ fontSize: typography.body, lineHeight: typography.body * 1.5 },
|
||||
]
|
||||
: styles.infoText;
|
||||
const reasonStyle = Platform.isTV
|
||||
? [styles.reasonTextTV, { fontSize: typography.callout }]
|
||||
: styles.reasonText;
|
||||
const boxStyle = Platform.isTV ? styles.infoBoxTV : styles.infoBox;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
animatedStyle,
|
||||
{
|
||||
top:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||
left:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||
},
|
||||
]}
|
||||
style={[styles.container, animatedStyle, containerStyle]}
|
||||
pointerEvents='none'
|
||||
>
|
||||
<View style={styles.infoBox}>
|
||||
<View style={boxStyle}>
|
||||
{playMethod && (
|
||||
<Text
|
||||
style={[
|
||||
styles.infoText,
|
||||
{ color: getPlayMethodColor(playMethod) },
|
||||
]}
|
||||
style={[textStyle, { color: getPlayMethodColor(playMethod) }]}
|
||||
>
|
||||
{getPlayMethodLabel(playMethod)}
|
||||
</Text>
|
||||
)}
|
||||
{transcodeReasons && transcodeReasons.length > 0 && (
|
||||
<Text style={[styles.infoText, styles.reasonText]}>
|
||||
<Text style={[textStyle, reasonStyle]}>
|
||||
{transcodeReasons.map(formatTranscodeReason).join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
{info?.videoWidth && info?.videoHeight && (
|
||||
<Text style={styles.infoText}>
|
||||
<Text style={textStyle}>
|
||||
{info.videoWidth}x{info.videoHeight}
|
||||
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
|
||||
{formatVideoRange(streamInfo?.videoRange)
|
||||
? ` ${formatVideoRange(streamInfo?.videoRange)}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.videoCodec && (
|
||||
<Text style={styles.infoText}>
|
||||
<Text style={textStyle}>
|
||||
Video: {formatCodec(info.videoCodec)}
|
||||
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.audioCodec && (
|
||||
<Text style={styles.infoText}>
|
||||
<Text style={textStyle}>
|
||||
Audio: {formatCodec(info.audioCodec)}
|
||||
{streamInfo?.audioChannels
|
||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
{streamInfo?.subtitleCodec && (
|
||||
<Text style={textStyle}>
|
||||
Subtitle: {formatCodec(streamInfo.subtitleCodec)}
|
||||
</Text>
|
||||
)}
|
||||
{(info?.videoBitrate || info?.audioBitrate) && (
|
||||
<Text style={styles.infoText}>
|
||||
<Text style={textStyle}>
|
||||
Bitrate:{" "}
|
||||
{info.videoBitrate
|
||||
? formatBitrate(info.videoBitrate)
|
||||
@@ -229,18 +346,22 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
</Text>
|
||||
)}
|
||||
{info?.cacheSeconds !== undefined && (
|
||||
<Text style={styles.infoText}>
|
||||
<Text style={textStyle}>
|
||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||
</Text>
|
||||
)}
|
||||
{info?.voDriver && (
|
||||
<Text style={textStyle}>
|
||||
VO: {info.voDriver}
|
||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||
<Text style={[styles.infoText, styles.warningText]}>
|
||||
<Text style={[textStyle, styles.warningText]}>
|
||||
Dropped: {info.droppedFrames} frames
|
||||
</Text>
|
||||
)}
|
||||
{!info && !playMethod && (
|
||||
<Text style={styles.infoText}>Loading...</Text>
|
||||
)}
|
||||
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
@@ -261,12 +382,23 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 8,
|
||||
minWidth: 150,
|
||||
},
|
||||
infoBoxTV: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
minWidth: 250,
|
||||
},
|
||||
infoText: {
|
||||
color: "white",
|
||||
fontSize: 12,
|
||||
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
|
||||
lineHeight: 18,
|
||||
},
|
||||
infoTextTV: {
|
||||
color: "white",
|
||||
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
|
||||
},
|
||||
warningText: {
|
||||
color: "#ff9800",
|
||||
},
|
||||
@@ -274,4 +406,7 @@ const styles = StyleSheet.create({
|
||||
color: "#fbbf24",
|
||||
fontSize: 10,
|
||||
},
|
||||
reasonTextTV: {
|
||||
color: "#fbbf24",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
@@ -16,6 +17,8 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
||||
currentTime,
|
||||
remainingTime,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getFinishTime = () => {
|
||||
const now = new Date();
|
||||
// remainingTime is in ms
|
||||
@@ -37,7 +40,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
||||
-{formatTimeString(remainingTime, "ms")}
|
||||
</Text>
|
||||
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
||||
ends at {getFinishTime()}
|
||||
{t("player.ends_at", { time: getFinishTime() })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -4,6 +4,12 @@ import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
|
||||
// Slightly larger preview (scale 1.6 vs old 1.4) to give the overlay text
|
||||
// more room and feel closer to the Jellyfin web style.
|
||||
const BASE_IMAGE_SCALE = 1.6;
|
||||
const BUBBLE_LEFT_OFFSET = 62;
|
||||
const BUBBLE_WIDTH_MULTIPLIER = 1.5;
|
||||
|
||||
interface TrickplayBubbleProps {
|
||||
trickPlayUrl: {
|
||||
x: number;
|
||||
@@ -22,12 +28,18 @@ interface TrickplayBubbleProps {
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
/** Scale factor for the image (default 1). Does not affect timestamp text. */
|
||||
imageScale?: number;
|
||||
/** Chapter name at the scrubbed position, if any. */
|
||||
chapterName?: string | null;
|
||||
}
|
||||
|
||||
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
imageScale = 1,
|
||||
chapterName,
|
||||
}) => {
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return null;
|
||||
@@ -36,18 +48,28 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
|
||||
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
|
||||
const timeStr = `${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`;
|
||||
|
||||
const finalScale = BASE_IMAGE_SCALE * imageScale;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: -62,
|
||||
// Sit just above the slider — high enough not to overlap the
|
||||
// progress bar, low enough to feel anchored to the thumb.
|
||||
left: -BUBBLE_LEFT_OFFSET * imageScale,
|
||||
bottom: 0,
|
||||
paddingTop: 30,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 5,
|
||||
width: tileWidth * 1.5,
|
||||
width: tileWidth * BUBBLE_WIDTH_MULTIPLIER * imageScale,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
// Bring the bubble in front of the player title / overlays.
|
||||
zIndex: 999,
|
||||
elevation: 10,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
@@ -55,13 +77,13 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
alignSelf: "center",
|
||||
transform: [{ scale: 1.4 }],
|
||||
transform: [{ scale: finalScale }],
|
||||
borderRadius: 5,
|
||||
}}
|
||||
className='bg-neutral-800 overflow-hidden'
|
||||
>
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
cachePolicy='memory-disk'
|
||||
style={{
|
||||
width: tileWidth * (trickplayInfo.data.TileWidth ?? 1),
|
||||
height:
|
||||
@@ -75,17 +97,51 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
source={{ uri: url }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
{/*
|
||||
* Bottom-right overlay (Jellyfin web style) — chapter name (small,
|
||||
* faded) above the timestamp (small, bold). Sits on top of the
|
||||
* trickplay frame inside the same overflow:hidden container so it
|
||||
* always stays within the bubble bounds.
|
||||
*/}
|
||||
<View
|
||||
pointerEvents='none'
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 4,
|
||||
bottom: 3,
|
||||
alignItems: "flex-start",
|
||||
paddingHorizontal: 3,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 3,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
maxWidth: tileWidth - 8,
|
||||
}}
|
||||
>
|
||||
{chapterName ? (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: 7,
|
||||
opacity: 0.85,
|
||||
lineHeight: 9,
|
||||
}}
|
||||
>
|
||||
{chapterName}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: 8,
|
||||
fontWeight: "600",
|
||||
lineHeight: 10,
|
||||
}}
|
||||
>
|
||||
{timeStr}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 30,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
export const CONTROLS_CONSTANTS = {
|
||||
TIMEOUT: 4000,
|
||||
SCRUB_INTERVAL_MS: 10 * 1000, // 10 seconds in ms
|
||||
SCRUB_INTERVAL_MS: 30 * 1000, // 30 seconds in ms
|
||||
SCRUB_INTERVAL_TICKS: 10 * 10000000, // 10 seconds in ticks
|
||||
TILE_WIDTH: 150,
|
||||
PROGRESS_UNIT_MS: 1000, // 1 second in ms
|
||||
PROGRESS_UNIT_TICKS: 10000000, // 1 second in ticks
|
||||
LONG_PRESS_INITIAL_SEEK: 10,
|
||||
LONG_PRESS_ACCELERATION: 1.1,
|
||||
LONG_PRESS_INITIAL_SEEK: 30,
|
||||
LONG_PRESS_ACCELERATION: 1.2,
|
||||
LONG_PRESS_MAX_ACCELERATION: 4,
|
||||
LONG_PRESS_INTERVAL: 300,
|
||||
SLIDER_DEBOUNCE_MS: 3,
|
||||
} as const;
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
*/
|
||||
|
||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { File } from "expo-file-system";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import {
|
||||
@@ -57,13 +58,19 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Platform } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import type { MpvAudioTrack } from "@/modules";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
||||
import type { Track } from "../types";
|
||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||
|
||||
// Starting index for local (client-downloaded) subtitles
|
||||
// Uses negative indices to avoid collision with Jellyfin indices
|
||||
const LOCAL_SUBTITLE_INDEX_START = -100;
|
||||
|
||||
interface VideoContextProps {
|
||||
subtitleTracks: Track[] | null;
|
||||
audioTracks: Track[] | null;
|
||||
@@ -339,12 +346,40 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
};
|
||||
});
|
||||
|
||||
// TV only: Merge locally downloaded subtitles (from OpenSubtitles)
|
||||
if (Platform.isTV && itemId) {
|
||||
const localSubs = getSubtitlesForItem(itemId);
|
||||
let localIdx = 0;
|
||||
for (const localSub of localSubs) {
|
||||
// Verify file still exists (cache may have been cleared)
|
||||
const subtitleFile = new File(localSub.filePath);
|
||||
if (!subtitleFile.exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
|
||||
subs.push({
|
||||
name: localSub.name,
|
||||
index: localIndex,
|
||||
mpvIndex: -1, // Will be loaded dynamically via addSubtitleFile
|
||||
isLocal: true,
|
||||
localPath: localSub.filePath,
|
||||
setTrack: () => {
|
||||
// Add the subtitle file to MPV and select it
|
||||
playerControls.addSubtitleFile(localSub.filePath, true);
|
||||
router.setParams({ subtitleIndex: String(localIndex) });
|
||||
},
|
||||
});
|
||||
localIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||
setAudioTracks(audio);
|
||||
};
|
||||
|
||||
fetchTracks();
|
||||
}, [tracksReady, mediaSource, offline, downloadedItem]);
|
||||
}, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
|
||||
|
||||
return (
|
||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||
|
||||
@@ -15,16 +15,18 @@ import { usePlayerContext } from "../contexts/PlayerContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
import { PlaybackSpeedScope } from "../utils/playback-speed-settings";
|
||||
|
||||
// Subtitle size presets (stored as scale * 100, so 1.0 = 100)
|
||||
const SUBTITLE_SIZE_PRESETS = [
|
||||
{ label: "0.5", value: 50 },
|
||||
{ label: "0.6", value: 60 },
|
||||
{ label: "0.7", value: 70 },
|
||||
{ label: "0.8", value: 80 },
|
||||
{ label: "0.9", value: 90 },
|
||||
{ label: "1.0", value: 100 },
|
||||
{ label: "1.1", value: 110 },
|
||||
{ label: "1.2", value: 120 },
|
||||
// Subtitle scale presets (direct multiplier values)
|
||||
const SUBTITLE_SCALE_PRESETS = [
|
||||
{ label: "0.1x", value: 0.1 },
|
||||
{ label: "0.25x", value: 0.25 },
|
||||
{ label: "0.5x", value: 0.5 },
|
||||
{ label: "0.75x", value: 0.75 },
|
||||
{ label: "1.0x", value: 1.0 },
|
||||
{ label: "1.25x", value: 1.25 },
|
||||
{ label: "1.5x", value: 1.5 },
|
||||
{ label: "2.0x", value: 2.0 },
|
||||
{ label: "2.5x", value: 2.5 },
|
||||
{ label: "3.0x", value: 3.0 },
|
||||
] as const;
|
||||
|
||||
interface DropdownViewProps {
|
||||
@@ -124,15 +126,15 @@ const DropdownView = ({
|
||||
})),
|
||||
});
|
||||
|
||||
// Subtitle Size Section
|
||||
// Subtitle Scale Section
|
||||
groups.push({
|
||||
title: "Subtitle Size",
|
||||
options: SUBTITLE_SIZE_PRESETS.map((preset) => ({
|
||||
title: "Subtitle Scale",
|
||||
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
||||
type: "radio" as const,
|
||||
label: preset.label,
|
||||
value: preset.value.toString(),
|
||||
selected: settings.subtitleSize === preset.value,
|
||||
onPress: () => updateSettings({ subtitleSize: preset.value }),
|
||||
selected: (settings.mpvSubtitleScale ?? 1.0) === preset.value,
|
||||
onPress: () => updateSettings({ mpvSubtitleScale: preset.value }),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -190,7 +192,7 @@ const DropdownView = ({
|
||||
audioTracksKey,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
settings.subtitleSize,
|
||||
settings.mpvSubtitleScale,
|
||||
updateSettings,
|
||||
playbackSpeed,
|
||||
setPlaybackSpeed,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useChapterNavigation } from "./useChapterNavigation";
|
||||
export { useRemoteControl } from "./useRemoteControl";
|
||||
export { useVideoNavigation } from "./useVideoNavigation";
|
||||
export { useVideoSlider } from "./useVideoSlider";
|
||||
|
||||
150
components/video-player/controls/hooks/useChapterNavigation.ts
Normal file
150
components/video-player/controls/hooks/useChapterNavigation.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
|
||||
export interface UseChapterNavigationProps {
|
||||
/** Chapters array from the item */
|
||||
chapters: ChapterInfo[] | null | undefined;
|
||||
/** Current progress in milliseconds (SharedValue) */
|
||||
progress: SharedValue<number>;
|
||||
/** Total duration in milliseconds */
|
||||
maxMs: number;
|
||||
/** Seek function that accepts milliseconds */
|
||||
seek: (ms: number) => void;
|
||||
}
|
||||
|
||||
export interface UseChapterNavigationReturn {
|
||||
/** Array of chapters */
|
||||
chapters: ChapterInfo[];
|
||||
/** Index of the current chapter (-1 if no chapters) */
|
||||
currentChapterIndex: number;
|
||||
/** Current chapter info or null */
|
||||
currentChapter: ChapterInfo | null;
|
||||
/** Whether there's a next chapter available */
|
||||
hasNextChapter: boolean;
|
||||
/** Whether there's a previous chapter available */
|
||||
hasPreviousChapter: boolean;
|
||||
/** Navigate to the next chapter */
|
||||
goToNextChapter: () => void;
|
||||
/** Navigate to the previous chapter (or restart current if >3s in) */
|
||||
goToPreviousChapter: () => void;
|
||||
/** Array of chapter positions as percentages (0-100) for tick marks */
|
||||
chapterPositions: number[];
|
||||
/** Whether chapters are available */
|
||||
hasChapters: boolean;
|
||||
}
|
||||
|
||||
// Threshold in ms - if more than 3 seconds into chapter, restart instead of going to previous
|
||||
const RESTART_THRESHOLD_MS = 3000;
|
||||
|
||||
/**
|
||||
* Hook for chapter navigation in video player
|
||||
* Provides current chapter info and navigation functions
|
||||
*/
|
||||
export function useChapterNavigation({
|
||||
chapters: rawChapters,
|
||||
progress,
|
||||
maxMs,
|
||||
seek,
|
||||
}: UseChapterNavigationProps): UseChapterNavigationReturn {
|
||||
// Ensure chapters is always an array
|
||||
const chapters = useMemo(() => rawChapters ?? [], [rawChapters]);
|
||||
|
||||
// Calculate chapter positions as percentages for tick marks
|
||||
const chapterPositions = useMemo(() => {
|
||||
if (!chapters.length || maxMs <= 0) return [];
|
||||
|
||||
return chapters
|
||||
.map((chapter) => {
|
||||
const positionMs = ticksToMs(chapter.StartPositionTicks);
|
||||
return (positionMs / maxMs) * 100;
|
||||
})
|
||||
.filter((pos) => pos > 0 && pos < 100); // Skip first (0%) and any at the end
|
||||
}, [chapters, maxMs]);
|
||||
|
||||
// Find current chapter index based on progress
|
||||
// The current chapter is the one with the largest StartPositionTicks that is <= current progress
|
||||
const getCurrentChapterIndex = useCallback((): number => {
|
||||
if (!chapters.length) return -1;
|
||||
|
||||
const currentMs = progress.value;
|
||||
let currentIndex = -1;
|
||||
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
const chapterMs = ticksToMs(chapters[i].StartPositionTicks);
|
||||
if (chapterMs <= currentMs) {
|
||||
currentIndex = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return currentIndex;
|
||||
}, [chapters, progress]);
|
||||
|
||||
// Current chapter index (computed once for rendering)
|
||||
const currentChapterIndex = getCurrentChapterIndex();
|
||||
|
||||
// Current chapter info
|
||||
const currentChapter = useMemo(() => {
|
||||
if (currentChapterIndex < 0 || currentChapterIndex >= chapters.length) {
|
||||
return null;
|
||||
}
|
||||
return chapters[currentChapterIndex];
|
||||
}, [chapters, currentChapterIndex]);
|
||||
|
||||
// Navigation availability
|
||||
const hasNextChapter =
|
||||
chapters.length > 0 && currentChapterIndex < chapters.length - 1;
|
||||
const hasPreviousChapter = chapters.length > 0 && currentChapterIndex >= 0;
|
||||
|
||||
// Navigate to next chapter
|
||||
const goToNextChapter = useCallback(() => {
|
||||
const idx = getCurrentChapterIndex();
|
||||
if (idx < chapters.length - 1) {
|
||||
const nextChapter = chapters[idx + 1];
|
||||
const nextMs = ticksToMs(nextChapter.StartPositionTicks);
|
||||
progress.value = nextMs;
|
||||
seek(nextMs);
|
||||
}
|
||||
}, [chapters, getCurrentChapterIndex, progress, seek]);
|
||||
|
||||
// Navigate to previous chapter (or restart current if >3s in)
|
||||
const goToPreviousChapter = useCallback(() => {
|
||||
const idx = getCurrentChapterIndex();
|
||||
if (idx < 0) return;
|
||||
|
||||
const currentChapterMs = ticksToMs(chapters[idx].StartPositionTicks);
|
||||
const currentMs = progress.value;
|
||||
const timeIntoChapter = currentMs - currentChapterMs;
|
||||
|
||||
// If more than 3 seconds into the current chapter, restart it
|
||||
// Otherwise, go to the previous chapter
|
||||
if (timeIntoChapter > RESTART_THRESHOLD_MS && idx >= 0) {
|
||||
progress.value = currentChapterMs;
|
||||
seek(currentChapterMs);
|
||||
} else if (idx > 0) {
|
||||
const prevChapter = chapters[idx - 1];
|
||||
const prevMs = ticksToMs(prevChapter.StartPositionTicks);
|
||||
progress.value = prevMs;
|
||||
seek(prevMs);
|
||||
} else {
|
||||
// At the first chapter, just restart it
|
||||
progress.value = currentChapterMs;
|
||||
seek(currentChapterMs);
|
||||
}
|
||||
}, [chapters, getCurrentChapterIndex, progress, seek]);
|
||||
|
||||
return {
|
||||
chapters,
|
||||
currentChapterIndex,
|
||||
currentChapter,
|
||||
hasNextChapter,
|
||||
hasPreviousChapter,
|
||||
goToNextChapter,
|
||||
goToPreviousChapter,
|
||||
chapterPositions,
|
||||
hasChapters: chapters.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -1,183 +1,237 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { CONTROLS_CONSTANTS } from "../constants";
|
||||
|
||||
// TV event handler with fallback for non-TV platforms
|
||||
let useTVEventHandler: (callback: (evt: any) => void) => void;
|
||||
if (Platform.isTV) {
|
||||
try {
|
||||
useTVEventHandler = require("react-native").useTVEventHandler;
|
||||
} catch {
|
||||
// Fallback for non-TV platforms
|
||||
useTVEventHandler = () => {};
|
||||
}
|
||||
} else {
|
||||
// No-op hook for non-TV platforms
|
||||
useTVEventHandler = () => {};
|
||||
}
|
||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
||||
|
||||
interface UseRemoteControlProps {
|
||||
progress: SharedValue<number>;
|
||||
min: SharedValue<number>;
|
||||
max: SharedValue<number>;
|
||||
showControls: boolean;
|
||||
isPlaying: boolean;
|
||||
seek: (value: number) => void;
|
||||
play: () => void;
|
||||
togglePlay: () => void;
|
||||
toggleControls: () => void;
|
||||
calculateTrickplayUrl: (progressInTicks: number) => void;
|
||||
handleSeekForward: (seconds: number) => void;
|
||||
handleSeekBackward: (seconds: number) => void;
|
||||
/** When true, disables handling D-pad events (e.g., when settings modal is open) */
|
||||
disableSeeking?: boolean;
|
||||
/** Callback for back/menu button press (tvOS: menu, Android TV: back) */
|
||||
onBack?: () => void;
|
||||
/** Callback to hide controls (called on back press when controls are visible) */
|
||||
onHideControls?: () => void;
|
||||
/** Title of the video being played (shown in exit confirmation) */
|
||||
videoTitle?: string;
|
||||
/** Whether the progress bar currently has focus */
|
||||
isProgressBarFocused?: boolean;
|
||||
/** Callback for seeking left when progress bar is focused */
|
||||
onSeekLeft?: () => void;
|
||||
/** Callback for seeking right when progress bar is focused */
|
||||
onSeekRight?: () => void;
|
||||
/** Callback for seeking left when controls are hidden (minimal seek mode) */
|
||||
onMinimalSeekLeft?: () => void;
|
||||
/** Callback for seeking right when controls are hidden (minimal seek mode) */
|
||||
onMinimalSeekRight?: () => void;
|
||||
/** Callback for any interaction that should reset the controls timeout */
|
||||
onInteraction?: () => void;
|
||||
/** Callback when long press seek left starts (eventKeyAction: 0) */
|
||||
onLongSeekLeftStart?: () => void;
|
||||
/** Callback when long press seek right starts (eventKeyAction: 0) */
|
||||
onLongSeekRightStart?: () => void;
|
||||
/** Callback when long press seek ends (eventKeyAction: 1) */
|
||||
onLongSeekStop?: () => void;
|
||||
/** Callback when up/down D-pad pressed (to show controls with play button focused) */
|
||||
onVerticalDpad?: () => void;
|
||||
/** Called before the exit confirmation Alert is shown (e.g., to pause countdown) */
|
||||
onWillExit?: () => void;
|
||||
/** Called when the user cancels the exit confirmation Alert */
|
||||
onCancelExit?: () => void;
|
||||
// Legacy props - kept for backwards compatibility with mobile Controls.tsx
|
||||
// These are ignored in the simplified implementation
|
||||
progress?: SharedValue<number>;
|
||||
min?: SharedValue<number>;
|
||||
max?: SharedValue<number>;
|
||||
isPlaying?: boolean;
|
||||
seek?: (value: number) => void;
|
||||
play?: () => void;
|
||||
togglePlay?: () => void;
|
||||
calculateTrickplayUrl?: (progressInTicks: number) => void;
|
||||
handleSeekForward?: (seconds: number) => void;
|
||||
handleSeekBackward?: (seconds: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage TV remote control interactions.
|
||||
* MPV player uses milliseconds for time values.
|
||||
* Simplified version - D-pad navigation is handled by native focus system.
|
||||
* This hook handles:
|
||||
* - Showing controls on any button press
|
||||
* - Play/pause button on TV remote
|
||||
*/
|
||||
export function useRemoteControl({
|
||||
progress,
|
||||
min,
|
||||
max,
|
||||
showControls,
|
||||
isPlaying,
|
||||
seek,
|
||||
play,
|
||||
togglePlay,
|
||||
toggleControls,
|
||||
calculateTrickplayUrl,
|
||||
handleSeekForward,
|
||||
handleSeekBackward,
|
||||
onBack,
|
||||
onHideControls,
|
||||
videoTitle,
|
||||
isProgressBarFocused,
|
||||
onSeekLeft,
|
||||
onSeekRight,
|
||||
onMinimalSeekLeft,
|
||||
onMinimalSeekRight,
|
||||
onInteraction,
|
||||
onLongSeekLeftStart,
|
||||
onLongSeekRightStart,
|
||||
onLongSeekStop,
|
||||
onVerticalDpad,
|
||||
onWillExit,
|
||||
onCancelExit,
|
||||
}: UseRemoteControlProps) {
|
||||
// Keep these for backward compatibility with the component
|
||||
const remoteScrubProgress = useSharedValue<number | null>(null);
|
||||
const isRemoteScrubbing = useSharedValue(false);
|
||||
const [showRemoteBubble, setShowRemoteBubble] = useState(false);
|
||||
const [longPressScrubMode, setLongPressScrubMode] = useState<
|
||||
"FF" | "RW" | null
|
||||
>(null);
|
||||
const [isSliding, setIsSliding] = useState(false);
|
||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||
const [showRemoteBubble] = useState(false);
|
||||
const [isSliding] = useState(false);
|
||||
const [time] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||
|
||||
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
// MPV uses ms
|
||||
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
|
||||
// Use refs to avoid stale closures in BackHandler
|
||||
const showControlsRef = useRef(showControls);
|
||||
const onHideControlsRef = useRef(onHideControls);
|
||||
const onBackRef = useRef(onBack);
|
||||
const videoTitleRef = useRef(videoTitle);
|
||||
const onWillExitRef = useRef(onWillExit);
|
||||
const onCancelExitRef = useRef(onCancelExit);
|
||||
|
||||
const updateTime = useCallback((progressValue: number) => {
|
||||
// Convert ms to ticks for calculation
|
||||
const progressInTicks = msToTicks(progressValue);
|
||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTime({ hours, minutes, seconds });
|
||||
useEffect(() => {
|
||||
showControlsRef.current = showControls;
|
||||
onHideControlsRef.current = onHideControls;
|
||||
onBackRef.current = onBack;
|
||||
videoTitleRef.current = videoTitle;
|
||||
onWillExitRef.current = onWillExit;
|
||||
onCancelExitRef.current = onCancelExit;
|
||||
}, [
|
||||
showControls,
|
||||
onHideControls,
|
||||
onBack,
|
||||
videoTitle,
|
||||
onWillExit,
|
||||
onCancelExit,
|
||||
]);
|
||||
|
||||
// BackHandler owns player exit: Android TV sends hardware back here, and
|
||||
// react-native-tvos maps the Apple TV menu button to the same API.
|
||||
useTVBackPress(() => {
|
||||
if (showControlsRef.current && onHideControlsRef.current) {
|
||||
// Controls are visible, so the first back press only hides them.
|
||||
onHideControlsRef.current();
|
||||
return true;
|
||||
}
|
||||
if (onBackRef.current) {
|
||||
// Signal Controls that exit is imminent (pauses countdown, sets guard)
|
||||
onWillExitRef.current?.();
|
||||
|
||||
// Controls are hidden, so confirm before leaving playback.
|
||||
Alert.alert(
|
||||
"Stop Playback",
|
||||
videoTitleRef.current
|
||||
? `Stop playing "${videoTitleRef.current}"?`
|
||||
: "Are you sure you want to stop playback?",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
onPress: () => onCancelExitRef.current?.(),
|
||||
},
|
||||
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
|
||||
],
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// TV remote control handling (no-op on non-TV platforms)
|
||||
useTVEventHandler((evt) => {
|
||||
if (!evt) return;
|
||||
|
||||
switch (evt.eventType) {
|
||||
case "longLeft": {
|
||||
setLongPressScrubMode((prev) => (!prev ? "RW" : null));
|
||||
break;
|
||||
}
|
||||
case "longRight": {
|
||||
setLongPressScrubMode((prev) => (!prev ? "FF" : null));
|
||||
break;
|
||||
}
|
||||
case "left":
|
||||
case "right": {
|
||||
isRemoteScrubbing.value = true;
|
||||
setShowRemoteBubble(true);
|
||||
|
||||
const direction = evt.eventType === "left" ? -1 : 1;
|
||||
const base = remoteScrubProgress.value ?? progress.value;
|
||||
const updated = Math.max(
|
||||
min.value,
|
||||
Math.min(max.value, base + direction * SCRUB_INTERVAL),
|
||||
);
|
||||
remoteScrubProgress.value = updated;
|
||||
// Convert ms to ticks for trickplay
|
||||
const progressInTicks = msToTicks(updated);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
updateTime(updated);
|
||||
break;
|
||||
}
|
||||
case "select": {
|
||||
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
|
||||
progress.value = remoteScrubProgress.value;
|
||||
|
||||
// MPV uses ms, seek expects ms
|
||||
const seekTarget = Math.max(0, remoteScrubProgress.value);
|
||||
|
||||
seek(seekTarget);
|
||||
if (isPlaying) play();
|
||||
|
||||
isRemoteScrubbing.value = false;
|
||||
remoteScrubProgress.value = null;
|
||||
setShowRemoteBubble(false);
|
||||
} else {
|
||||
togglePlay();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "down":
|
||||
case "up":
|
||||
// cancel scrubbing on other directions
|
||||
isRemoteScrubbing.value = false;
|
||||
remoteScrubProgress.value = null;
|
||||
setShowRemoteBubble(false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
// Back/menu is handled by useTVBackPress above. Keep this handler focused
|
||||
// on remote-control events like play/pause, D-pad, and long seek.
|
||||
if (evt.eventType === "menu") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showControls) toggleControls();
|
||||
// Handle play/pause button press on TV remote
|
||||
if (evt.eventType === "playPause") {
|
||||
togglePlay?.();
|
||||
onInteraction?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle long press D-pad for continuous seeking (works in both modes)
|
||||
// Must be checked BEFORE the showControls check to work when controls are hidden
|
||||
if (evt.eventType === "longLeft") {
|
||||
if (evt.eventKeyAction === 0 && onLongSeekLeftStart) {
|
||||
// Key pressed - start continuous seeking backward
|
||||
onLongSeekLeftStart();
|
||||
} else if (evt.eventKeyAction === 1 && onLongSeekStop) {
|
||||
// Key released - stop seeking
|
||||
onLongSeekStop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.eventType === "longRight") {
|
||||
if (evt.eventKeyAction === 0 && onLongSeekRightStart) {
|
||||
// Key pressed - start continuous seeking forward
|
||||
onLongSeekRightStart();
|
||||
} else if (evt.eventKeyAction === 1 && onLongSeekStop) {
|
||||
// Key released - stop seeking
|
||||
onLongSeekStop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle D-pad when controls are hidden
|
||||
if (!showControls) {
|
||||
// Ignore select/enter events - let the native Pressable handle them
|
||||
// This prevents controls from showing when pressing buttons like skip intro
|
||||
if (evt.eventType === "select" || evt.eventType === "enter") {
|
||||
return;
|
||||
}
|
||||
// Minimal seek mode for left/right
|
||||
if (evt.eventType === "left" && onMinimalSeekLeft) {
|
||||
onMinimalSeekLeft();
|
||||
return;
|
||||
}
|
||||
if (evt.eventType === "right" && onMinimalSeekRight) {
|
||||
onMinimalSeekRight();
|
||||
return;
|
||||
}
|
||||
// Up/down shows controls with play button focused
|
||||
if (
|
||||
(evt.eventType === "up" || evt.eventType === "down") &&
|
||||
onVerticalDpad
|
||||
) {
|
||||
onVerticalDpad();
|
||||
return;
|
||||
}
|
||||
// Ignore all other events (focus/blur, swipes, etc.)
|
||||
// User can press up/down to show controls
|
||||
return;
|
||||
}
|
||||
|
||||
// Controls are showing - handle seeking when progress bar is focused
|
||||
if (isProgressBarFocused) {
|
||||
if (evt.eventType === "left" && onSeekLeft) {
|
||||
onSeekLeft();
|
||||
return;
|
||||
}
|
||||
if (evt.eventType === "right" && onSeekRight) {
|
||||
onSeekRight();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the timeout on any D-pad navigation when controls are showing
|
||||
onInteraction?.();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
let seekTime = CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK;
|
||||
|
||||
const scrubWithLongPress = () => {
|
||||
if (!isActive || !longPressScrubMode) return;
|
||||
|
||||
setIsSliding(true);
|
||||
const scrubFn =
|
||||
longPressScrubMode === "FF" ? handleSeekForward : handleSeekBackward;
|
||||
scrubFn(seekTime);
|
||||
seekTime *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION;
|
||||
|
||||
longPressTimeoutRef.current = setTimeout(
|
||||
scrubWithLongPress,
|
||||
CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL,
|
||||
);
|
||||
};
|
||||
|
||||
if (longPressScrubMode) {
|
||||
isActive = true;
|
||||
scrubWithLongPress();
|
||||
}
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
setIsSliding(false);
|
||||
if (longPressTimeoutRef.current) {
|
||||
clearTimeout(longPressTimeoutRef.current);
|
||||
longPressTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [longPressScrubMode, handleSeekForward, handleSeekBackward]);
|
||||
|
||||
return {
|
||||
remoteScrubProgress,
|
||||
isRemoteScrubbing,
|
||||
showRemoteBubble,
|
||||
longPressScrubMode,
|
||||
isSliding,
|
||||
time,
|
||||
};
|
||||
|
||||
@@ -74,6 +74,21 @@ export function useVideoSlider({
|
||||
[seek, play, progress, isSeeking],
|
||||
);
|
||||
|
||||
// Programmatic seek (chapter list, hotkeys) that bypasses the slide gesture.
|
||||
// Reads `isPlaying` directly instead of `wasPlayingRef`, which is only set
|
||||
// during a real slide and would carry stale state on a tap-to-seek.
|
||||
const seekTo = useCallback(
|
||||
(value: number) => {
|
||||
const seekValue = Math.max(0, Math.floor(value));
|
||||
progress.value = seekValue;
|
||||
seek(seekValue);
|
||||
if (isPlaying) {
|
||||
play();
|
||||
}
|
||||
},
|
||||
[seek, play, progress, isPlaying],
|
||||
);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
debounce((value: number) => {
|
||||
// Convert ms to ticks for trickplay
|
||||
@@ -96,5 +111,6 @@ export function useVideoSlider({
|
||||
handleTouchEnd,
|
||||
handleSliderComplete,
|
||||
handleSliderChange,
|
||||
seekTo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ type Track = {
|
||||
index: number;
|
||||
mpvIndex?: number;
|
||||
setTrack: () => void;
|
||||
/** True for client-side downloaded subtitles (e.g., from OpenSubtitles) */
|
||||
isLocal?: boolean;
|
||||
/** File path for local subtitles */
|
||||
localPath?: string;
|
||||
};
|
||||
|
||||
export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
|
||||
export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle };
|
||||
|
||||
Reference in New Issue
Block a user