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:
Gauvain
2026-06-01 23:14:35 +02:00
435 changed files with 48123 additions and 6489 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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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