Merge remote-tracking branch 'origin/develop' into feat/local-intros

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
Lance Chant
2026-07-17 14:25:05 +02:00
545 changed files with 53870 additions and 13458 deletions

View File

@@ -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

View File

@@ -1,18 +1,34 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
import { View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
ChapterInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { type FC, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChapterList } from "@/components/chapters/ChapterList";
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import { Text } from "@/components/common/Text";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
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. */
chapters?: ChapterInfo[] | null;
/** Total media duration in milliseconds. */
durationMs: number;
showControls: boolean;
isSliding: boolean;
showRemoteBubble: boolean;
@@ -40,6 +56,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: {
@@ -68,6 +86,8 @@ interface BottomControlsProps {
export const BottomControls: FC<BottomControlsProps> = ({
item,
chapters,
durationMs,
showControls,
isSliding,
showRemoteBubble,
@@ -93,6 +113,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
handleSliderChange,
handleTouchStart,
handleTouchEnd,
seekTo,
trickPlayUrl,
trickplayInfo,
time,
@@ -101,27 +122,48 @@ export const BottomControls: FC<BottomControlsProps> = ({
intros = [],
}) => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const insets = useControlsSafeAreaInsets();
const [chapterListVisible, setChapterListVisible] = useState(false);
// Only expose chapter UI when there are at least two real markers.
const chapterMarkerList = useMemo(
() => chapterMarkers(chapters, durationMs),
[chapters, durationMs],
);
const hasChapters = chapterMarkerList.length > 1;
// 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={[
{
position: "absolute",
right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
bottom:
(settings?.safeAreaInControlsEnabled ?? true)
? Math.max(insets.bottom - 17, 0)
: 0,
right: insets.right,
left: insets.left,
bottom: Math.max(insets.bottom - 17, 0),
},
]}
className={"flex flex-col px-2"}
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",
@@ -143,8 +185,13 @@ 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 space-x-2 shrink-0'>
<View className='flex flex-row items-center space-x-2 shrink-0'>
{/* Skip Intro button - shows when playing intro videos */}
{isPlayingIntro && intros.length > 0 && skipAllIntros && (
<SkipButton
@@ -184,6 +231,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
onPress={handleNextEpisodeManual}
/>
)}
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center ml-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
</View>
</View>
<View
@@ -196,6 +254,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}
@@ -223,6 +284,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={time}
chapterName={scrubChapterName}
/>
)
}
@@ -232,6 +294,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
minimumValue={min}
maximumValue={max}
/>
<ChapterTicks markers={chapterMarkerList} height={TICK_HEIGHT} />
</View>
<TimeDisplay
currentTime={currentTime}
@@ -239,6 +302,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
/>
</View>
</View>
<ChapterList
visible={chapterListVisible}
chapters={chapters}
currentPositionMs={currentTime}
onSeek={seekTo}
onClose={() => setChapterListVisible(false)}
/>
</View>
);
};

View File

@@ -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

View File

@@ -1,9 +1,9 @@
import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
@@ -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,22 +35,27 @@ export const CenterControls: FC<CenterControlsProps> = ({
togglePlay,
handleSkipBackward,
handleSkipForward,
hasChapters = false,
hasPreviousChapter = false,
hasNextChapter = false,
goToPreviousChapter,
goToNextChapter,
}) => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const insets = useControlsSafeAreaInsets();
return (
<View
style={{
position: "absolute",
top: "50%",
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
left: insets.left,
right: insets.right,
flexDirection: "row",
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

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

View File

@@ -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";
@@ -241,6 +242,20 @@ export const Controls: FC<Props> = ({
isSeeking,
});
// Chapter navigation hook
const {
hasChapters,
hasPreviousChapter,
hasNextChapter,
goToPreviousChapter,
goToNextChapter,
} = useChapterNavigation({
chapters: item.Chapters,
progress,
maxMs,
seek,
});
const toggleControls = useCallback(() => {
if (showControls) {
setShowAudioSlider(false);
@@ -281,6 +296,7 @@ export const Controls: FC<Props> = ({
handleTouchEnd,
handleSliderComplete,
handleSliderChange,
seekTo,
} = useVideoSlider({
progress,
isSeeking,
@@ -464,12 +480,19 @@ 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({
// Use setParams instead of replace to avoid unmounting/remounting the player,
// which would create a new MPV native view and crash with "mp_initialize already initialized".
router.setParams({
...(offline && { offline: "true" }),
itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
@@ -478,11 +501,17 @@ export const Controls: FC<Props> = ({
bitrateValue: bitrateValue?.toString(),
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
});
},
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
[
settings,
subtitleIndex,
audioIndex,
mediaSource,
bitrateValue,
router,
offline,
],
);
const goToPreviousItem = useCallback(() => {
@@ -606,6 +635,7 @@ export const Controls: FC<Props> = ({
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
mediaSource={mediaSource}
/>
)}
<Animated.View
@@ -645,6 +675,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
@@ -653,6 +688,8 @@ export const Controls: FC<Props> = ({
>
<BottomControls
item={item}
chapters={item.Chapters}
durationMs={maxMs}
showControls={showControls}
isSliding={isSliding}
showRemoteBubble={showRemoteBubble}
@@ -678,6 +715,7 @@ export const Controls: FC<Props> = ({
handleSliderChange={handleSliderChange}
handleTouchStart={handleTouchStart}
handleTouchEnd={handleTouchEnd}
seekTo={seekTo}
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import {
HorizontalScroll,
@@ -17,10 +16,10 @@ import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
getDownloadedEpisodesForSeason,
getDownloadedSeasonNumbers,
@@ -46,8 +45,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
scrollViewRef.current?.scrollToIndex(index, 100);
};
const isOffline = useOfflineMode();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const insets = useControlsSafeAreaInsets();
// Set the initial season index
useEffect(() => {
@@ -59,6 +57,11 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
}
}, []);
// Read the live (cached) downloads DB inside the query rather than the
// provider's downloadedItems snapshot. The snapshot only refreshes on the
// provider refreshKey, so after updateDownloadedItem() invalidates
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
const { getDownloadedItems } = useDownload();
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
@@ -182,12 +185,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
backgroundColor: "black",
height: "100%",
width: "100%",
paddingTop:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
paddingLeft:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
paddingRight:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View

View File

@@ -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;

View File

@@ -5,12 +5,11 @@ import type {
} from "@jellyfin/sdk/lib/generated-client";
import { type FC, useCallback, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import useRouter from "@/hooks/useAppRouter";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { OrientationLock } from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
@@ -58,9 +57,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
showTechnicalInfo = false,
onToggleTechnicalInfo,
}) => {
const { settings } = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
const insets = useControlsSafeAreaInsets();
const lightHapticFeedback = useHaptic("light");
const { orientation, lockOrientation } = useOrientation();
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
@@ -99,10 +97,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
style={[
{
position: "absolute",
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
top: insets.top,
left: insets.left,
right: insets.right,
padding: HEADER_LAYOUT.CONTAINER_PADDING,
},
]}
@@ -123,7 +120,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}

View File

@@ -7,6 +7,7 @@ import {
View,
} from "react-native";
import Animated, {
cancelAnimation,
Easing,
runOnJS,
useAnimatedStyle,
@@ -45,6 +46,11 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
}
},
);
// Cancel animation on unmount to prevent onFinish from firing after exit
return () => {
cancelAnimation(progress);
};
}
}, [show, onFinish]);

View File

@@ -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,8 +15,9 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT } from "./constants";
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
@@ -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";
@@ -121,18 +174,55 @@ const formatTranscodeReason = (reason: string): string => {
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
({
showControls,
showControls: _showControls,
visible,
getTechnicalInfo,
playMethod,
transcodeReasons,
mediaSource,
currentSubtitleIndex,
currentAudioIndex,
}) => {
const { settings } = useSettings();
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
const safeInsets = useControlsSafeAreaInsets();
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, {
@@ -168,64 +258,79 @@ 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: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
left: safeInsets.left + 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)
@@ -235,18 +340,34 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
</Text>
)}
{info?.cacheSeconds !== undefined && (
<Text style={styles.infoText}>
<Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s
{info?.demuxerMaxBytes !== undefined
? ` (cap ${info.demuxerMaxBytes}MB` +
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
`${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` +
")"
: ""}
</Text>
)}
{info?.voDriver && (
<Text style={textStyle}>
VO: {info.voDriver}
{info.hwdec ? ` / ${info.hwdec}` : ""}
</Text>
)}
{info?.estimatedVfFps !== undefined && (
<Text style={textStyle}>
Output FPS: {info.estimatedVfFps.toFixed(2)}
{info?.fps ? ` (container ${formatFps(info.fps)})` : ""}
</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>
);
@@ -267,12 +388,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",
},
@@ -280,4 +412,7 @@ const styles = StyleSheet.create({
color: "#fbbf24",
fontSize: 10,
},
reasonTextTV: {
color: "#fbbf24",
},
});

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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 }}>

View File

@@ -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,

View File

@@ -1,3 +1,4 @@
export { useChapterNavigation } from "./useChapterNavigation";
export { useRemoteControl } from "./useRemoteControl";
export { useVideoNavigation } from "./useVideoNavigation";
export { useVideoSlider } from "./useVideoSlider";

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

View File

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

View File

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

View File

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