diff --git a/.gitignore b/.gitignore
index 2b3bd6ed..b8c7526a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,8 +50,6 @@ npm-debug.*
.idea/
.ruby-lsp
.cursor/
-.claude/
-CLAUDE.md
# Environment and Configuration
expo-env.d.ts
@@ -72,4 +70,5 @@ modules/background-downloader/android/build/*
/modules/mpv-player/android/build
# ios:unsigned-build Artifacts
-build/
\ No newline at end of file
+build/
+.claude/settings.local.json
diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
index d96bf5b5..e80a47a6 100644
--- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
+++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
@@ -15,7 +15,7 @@ import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
-import { FlatList, useWindowDimensions, View } from "react-native";
+import { FlatList, Platform, useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
@@ -24,6 +24,7 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
+import { TV_POSTER_WIDTH } from "@/components/posters/MoviePoster.tv";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -49,6 +50,20 @@ import {
} from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
+const TV_ITEM_GAP = 16;
+const TV_SCALE_PADDING = 20;
+
+const _TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
+
+
+ {item.Name}
+
+
+ {item.ProductionYear}
+
+
+);
+
const Page = () => {
const searchParams = useLocalSearchParams() as {
libraryId: string;
@@ -162,6 +177,14 @@ const Page = () => {
);
const nrOfCols = useMemo(() => {
+ if (Platform.isTV) {
+ // Calculate columns based on TV poster width + gap
+ const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
+ return Math.max(
+ 1,
+ Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
+ );
+ }
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
diff --git a/components/Badge.tsx b/components/Badge.tsx
index a694e3a5..c5806c8d 100644
--- a/components/Badge.tsx
+++ b/components/Badge.tsx
@@ -38,21 +38,37 @@ export const Badge: React.FC = ({
);
}
+ // On TV, use transparent backgrounds for a cleaner look
+ const isTV = Platform.isTV;
+
return (
- {iconLeft && {iconLeft}}
+ {iconLeft && {iconLeft}}
{text}
diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx
index 2476fbff..e3047c62 100644
--- a/components/ItemContent.tv.tsx
+++ b/components/ItemContent.tv.tsx
@@ -505,12 +505,10 @@ const TVOptionButton: React.FC<{
>
{label}
@@ -527,7 +525,7 @@ const TVOptionButton: React.FC<{
= React.memo(
// Subtitle options for selector (with "None" option)
const subtitleOptions = useMemo(() => {
const noneOption = {
- label: t("subtitles.none") || "None",
+ label: t("item_card.subtitles.none"),
value: -1,
selected: selectedOptions?.subtitleIndex === -1,
};
@@ -729,7 +727,7 @@ export const ItemContentTV: React.FC = React.memo(
const selectedSubtitleLabel = useMemo(() => {
if (selectedOptions?.subtitleIndex === -1)
- return t("subtitles.none") || "None";
+ return t("item_card.subtitles.none");
const track = subtitleTracks.find(
(t) => t.Index === selectedOptions?.subtitleIndex,
);
diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx
index 20b9cb05..52fed37b 100644
--- a/components/login/TVLogin.tsx
+++ b/components/login/TVLogin.tsx
@@ -17,7 +17,10 @@ import { z } from "zod";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TVInput } from "@/components/login/TVInput";
-import { TVPreviousServersList } from "@/components/login/TVPreviousServersList";
+import {
+ TVPreviousServersList,
+ TVServerActionSheet,
+} from "@/components/login/TVPreviousServersList";
import { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle";
import { TVServerCard } from "@/components/login/TVServerCard";
import { PasswordEntryModal } from "@/components/PasswordEntryModal";
@@ -26,10 +29,11 @@ import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
-import type {
- AccountSecurityType,
- SavedServer,
- SavedServerAccount,
+import {
+ type AccountSecurityType,
+ removeServerFromList,
+ type SavedServer,
+ type SavedServerAccount,
} from "@/utils/secureCredentials";
const CredentialsSchema = z.object({
@@ -84,6 +88,14 @@ export const TVLogin: React.FC = () => {
const [selectedAccount, setSelectedAccount] =
useState(null);
+ // Server action sheet state
+ const [showServerActionSheet, setShowServerActionSheet] = useState(false);
+ const [actionSheetServer, setActionSheetServer] =
+ useState(null);
+ const [loginTriggerServer, setLoginTriggerServer] =
+ useState(null);
+ const [actionSheetKey, setActionSheetKey] = useState(0);
+
// Server discovery
const {
servers: discoveredServers,
@@ -243,6 +255,50 @@ export const TVLogin: React.FC = () => {
}
};
+ // Server action sheet handlers
+ const handleServerAction = (server: SavedServer) => {
+ setActionSheetServer(server);
+ setActionSheetKey((k) => k + 1); // Force remount to reset focus
+ setShowServerActionSheet(true);
+ };
+
+ const handleServerActionLogin = () => {
+ setShowServerActionSheet(false);
+ if (actionSheetServer) {
+ // Trigger the login flow in TVPreviousServersList
+ setLoginTriggerServer(actionSheetServer);
+ // Reset the trigger after a tick to allow re-triggering the same server
+ setTimeout(() => setLoginTriggerServer(null), 0);
+ }
+ };
+
+ const handleServerActionDelete = () => {
+ if (!actionSheetServer) return;
+
+ Alert.alert(
+ t("server.remove_server"),
+ t("server.remove_server_description", {
+ server: actionSheetServer.name || actionSheetServer.address,
+ }),
+ [
+ {
+ text: t("common.cancel"),
+ style: "cancel",
+ onPress: () => setShowServerActionSheet(false),
+ },
+ {
+ text: t("common.delete"),
+ style: "destructive",
+ onPress: async () => {
+ await removeServerFromList(actionSheetServer.address);
+ setShowServerActionSheet(false);
+ setActionSheetServer(null);
+ },
+ },
+ ],
+ );
+ };
+
const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true);
const baseUrl = url.replace(/^https?:\/\//i, "");
@@ -593,6 +649,8 @@ export const TVLogin: React.FC = () => {
onAddAccount={handleAddAccount}
onPinRequired={handlePinRequired}
onPasswordRequired={handlePasswordRequired}
+ onServerAction={handleServerAction}
+ loginServerOverride={loginTriggerServer}
/>
@@ -637,6 +695,16 @@ export const TVLogin: React.FC = () => {
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
+
+ {/* Server Action Sheet */}
+ setShowServerActionSheet(false)}
+ />
);
};
diff --git a/components/login/TVPreviousServersList.tsx b/components/login/TVPreviousServersList.tsx
index a2afce43..8105db0e 100644
--- a/components/login/TVPreviousServersList.tsx
+++ b/components/login/TVPreviousServersList.tsx
@@ -1,7 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import type React from "react";
-import { useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
@@ -18,7 +18,6 @@ import { Text } from "@/components/common/Text";
import {
deleteAccountCredential,
getPreviousServers,
- removeServerFromList,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
@@ -123,79 +122,86 @@ const TVServerActionSheet: React.FC<{
}> = ({ visible, server, onLogin, onDelete, onClose }) => {
const { t } = useTranslation();
- if (!visible || !server) return null;
+ if (!server) return null;
return (
-
-
-
- {/* Title */}
-
- {server.name || server.address}
-
+ {/* Title */}
+
+ {server.name || server.address}
+
- {/* Horizontal options */}
-
-
-
-
-
-
-
+ {/* Horizontal options */}
+
+
+
+
+
+
+
+
+
);
};
@@ -213,14 +219,23 @@ interface TVPreviousServersListProps {
server: SavedServer,
account: SavedServerAccount,
) => void;
+ // Called when server is pressed to show action sheet (handled by parent)
+ onServerAction?: (server: SavedServer) => void;
+ // Called by parent when "Login" is selected from action sheet
+ loginServerOverride?: SavedServer | null;
}
+// Export the action sheet for use in parent components
+export { TVServerActionSheet };
+
export const TVPreviousServersList: React.FC = ({
onServerSelect,
onQuickLogin,
onAddAccount,
onPinRequired,
onPasswordRequired,
+ onServerAction,
+ loginServerOverride,
}) => {
const { t } = useTranslation();
const [_previousServers, setPreviousServers] =
@@ -230,12 +245,30 @@ export const TVPreviousServersList: React.FC = ({
null,
);
const [showAccountsModal, setShowAccountsModal] = useState(false);
- const [showActionSheet, setShowActionSheet] = useState(false);
const previousServers = useMemo(() => {
return JSON.parse(_previousServers || "[]") as SavedServer[];
}, [_previousServers]);
+ // When parent triggers login via loginServerOverride, execute the login flow
+ useEffect(() => {
+ if (loginServerOverride) {
+ const accountCount = loginServerOverride.accounts?.length || 0;
+
+ if (accountCount === 0) {
+ onServerSelect(loginServerOverride);
+ } else if (accountCount === 1) {
+ handleAccountLogin(
+ loginServerOverride,
+ loginServerOverride.accounts[0],
+ );
+ } else {
+ setSelectedServer(loginServerOverride);
+ setShowAccountsModal(true);
+ }
+ }
+ }, [loginServerOverride]);
+
const refreshServers = () => {
const servers = getPreviousServers();
setPreviousServers(JSON.stringify(servers));
@@ -281,53 +314,25 @@ export const TVPreviousServersList: React.FC = ({
const handleServerPress = (server: SavedServer) => {
if (loadingServer) return;
- setSelectedServer(server);
- setShowActionSheet(true);
- };
- const handleServerLoginAction = () => {
- if (!selectedServer) return;
- setShowActionSheet(false);
-
- const accountCount = selectedServer.accounts?.length || 0;
+ // If onServerAction is provided, delegate to parent for action sheet handling
+ if (onServerAction) {
+ onServerAction(server);
+ return;
+ }
+ // Fallback: direct login flow (for backwards compatibility)
+ const accountCount = server.accounts?.length || 0;
if (accountCount === 0) {
- onServerSelect(selectedServer);
+ onServerSelect(server);
} else if (accountCount === 1) {
- handleAccountLogin(selectedServer, selectedServer.accounts[0]);
+ handleAccountLogin(server, server.accounts[0]);
} else {
+ setSelectedServer(server);
setShowAccountsModal(true);
}
};
- const handleServerDeleteAction = () => {
- if (!selectedServer) return;
-
- Alert.alert(
- t("server.remove_server"),
- t("server.remove_server_description", {
- server: selectedServer.name || selectedServer.address,
- }),
- [
- {
- text: t("common.cancel"),
- style: "cancel",
- onPress: () => setShowActionSheet(false),
- },
- {
- text: t("common.delete"),
- style: "destructive",
- onPress: async () => {
- await removeServerFromList(selectedServer.address);
- refreshServers();
- setShowActionSheet(false);
- setSelectedServer(null);
- },
- },
- ],
- );
- };
-
const getServerSubtitle = (server: SavedServer): string | undefined => {
const accountCount = server.accounts?.length || 0;
@@ -498,15 +503,6 @@ export const TVPreviousServersList: React.FC = ({
-
- {/* TV Server Action Sheet */}
- setShowActionSheet(false)}
- />
);
};
diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx
index a5a25a55..1684734e 100644
--- a/components/video-player/controls/Controls.tv.tsx
+++ b/components/video-player/controls/Controls.tv.tsx
@@ -3,8 +3,24 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
-import { type FC, useCallback, useEffect } from "react";
-import { StyleSheet, View } from "react-native";
+import { BlurView } from "expo-blur";
+import {
+ type FC,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Pressable,
+ Animated as RNAnimated,
+ Easing as RNEasing,
+ ScrollView,
+ StyleSheet,
+ View,
+} from "react-native";
import { Slider } from "react-native-awesome-slider";
import Animated, {
Easing,
@@ -39,11 +55,430 @@ interface Props {
seek: (ticks: number) => void;
play: () => void;
pause: () => void;
+ audioIndex?: number;
+ subtitleIndex?: number;
+ onAudioIndexChange?: (index: number) => void;
+ onSubtitleIndexChange?: (index: number) => void;
}
const TV_SEEKBAR_HEIGHT = 16;
const TV_AUTO_HIDE_TIMEOUT = 5000;
+// Option item type for TV selector
+type TVOptionItem = {
+ label: string;
+ value: T;
+ selected: boolean;
+};
+
+// TV Option Selector - Bottom sheet with horizontal scrolling
+const TVOptionSelector = ({
+ visible,
+ title,
+ options,
+ onSelect,
+ onClose,
+}: {
+ visible: boolean;
+ title: string;
+ options: TVOptionItem[];
+ onSelect: (value: T) => void;
+ onClose: () => void;
+}) => {
+ const initialSelectedIndex = useMemo(() => {
+ const idx = options.findIndex((o) => o.selected);
+ return idx >= 0 ? idx : 0;
+ }, [options]);
+
+ if (!visible) return null;
+
+ return (
+
+
+
+ {title}
+
+ {options.map((option, index) => (
+ {
+ onSelect(option.value);
+ onClose();
+ }}
+ />
+ ))}
+
+
+
+
+ );
+};
+
+// Option card for horizontal selector
+const TVOptionCard: FC<{
+ label: string;
+ selected: boolean;
+ hasTVPreferredFocus?: boolean;
+ onPress: () => void;
+}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new RNAnimated.Value(1)).current;
+
+ const animateTo = (v: number) =>
+ RNAnimated.timing(scale, {
+ toValue: v,
+ duration: 150,
+ easing: RNEasing.out(RNEasing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
+
+ {label}
+
+ {selected && !focused && (
+
+
+
+ )}
+
+
+ );
+};
+
+// Settings panel with tabs for Audio and Subtitles
+const TVSettingsPanel: FC<{
+ visible: boolean;
+ audioOptions: TVOptionItem[];
+ subtitleOptions: TVOptionItem[];
+ onAudioSelect: (value: number) => void;
+ onSubtitleSelect: (value: number) => void;
+ onClose: () => void;
+ t: (key: string) => string;
+}> = ({
+ visible,
+ audioOptions,
+ subtitleOptions,
+ onAudioSelect,
+ onSubtitleSelect,
+ onClose,
+ t,
+}) => {
+ const [activeTab, setActiveTab] = useState<"audio" | "subtitle">("audio");
+
+ const currentOptions = activeTab === "audio" ? audioOptions : subtitleOptions;
+ const currentOnSelect =
+ activeTab === "audio" ? onAudioSelect : onSubtitleSelect;
+
+ const initialSelectedIndex = useMemo(() => {
+ const idx = currentOptions.findIndex((o) => o.selected);
+ return idx >= 0 ? idx : 0;
+ }, [currentOptions]);
+
+ if (!visible) return null;
+
+ return (
+
+
+
+ {/* Tab buttons - no preferred focus, navigate here via up from options */}
+
+ {audioOptions.length > 0 && (
+ setActiveTab("audio")}
+ />
+ )}
+ {subtitleOptions.length > 0 && (
+ setActiveTab("subtitle")}
+ />
+ )}
+
+
+ {/* Options - first selected option gets preferred focus */}
+
+ {currentOptions.map((option, index) => (
+ {
+ currentOnSelect(option.value);
+ onClose();
+ }}
+ />
+ ))}
+
+
+
+
+ );
+};
+
+// Tab button for settings panel
+const TVSettingsTab: FC<{
+ label: string;
+ active: boolean;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+}> = ({ label, active, onPress, hasTVPreferredFocus }) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new RNAnimated.Value(1)).current;
+
+ const animateTo = (v: number) =>
+ RNAnimated.timing(scale, {
+ toValue: v,
+ duration: 120,
+ easing: RNEasing.out(RNEasing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
+
+ {label}
+
+
+
+ );
+};
+
+// Button to open option selector (kept for potential future use)
+const _TVControlButton: FC<{
+ icon: keyof typeof Ionicons.glyphMap;
+ label: string;
+ onPress: () => void;
+ disabled?: boolean;
+ onFocusChange?: (focused: boolean) => void;
+}> = ({ icon, label, onPress, disabled, onFocusChange }) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new RNAnimated.Value(1)).current;
+
+ const animateTo = (v: number) =>
+ RNAnimated.timing(scale, {
+ toValue: v,
+ duration: 120,
+ easing: RNEasing.out(RNEasing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.08);
+ onFocusChange?.(true);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ onFocusChange?.(false);
+ }}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+
+ {label}
+
+
+
+ );
+};
+
+const selectorStyles = StyleSheet.create({
+ overlay: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "flex-end",
+ zIndex: 1000,
+ },
+ blurContainer: {
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ overflow: "hidden",
+ },
+ content: {
+ paddingTop: 24,
+ paddingBottom: 50,
+ overflow: "visible",
+ },
+ title: {
+ fontSize: 18,
+ fontWeight: "500",
+ color: "rgba(255,255,255,0.6)",
+ marginBottom: 16,
+ paddingHorizontal: 48,
+ textTransform: "uppercase",
+ letterSpacing: 1,
+ },
+ scrollView: {
+ overflow: "visible",
+ },
+ scrollContent: {
+ paddingHorizontal: 48,
+ paddingVertical: 10,
+ gap: 12,
+ },
+ card: {
+ width: 180,
+ height: 80,
+ borderRadius: 14,
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: 12,
+ },
+ cardText: {
+ fontSize: 16,
+ textAlign: "center",
+ },
+ checkmark: {
+ position: "absolute",
+ top: 8,
+ right: 8,
+ },
+ controlButton: {
+ borderRadius: 10,
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ borderWidth: 2,
+ flexDirection: "row",
+ alignItems: "center",
+ },
+ controlButtonText: {
+ fontSize: 14,
+ fontWeight: "500",
+ },
+ tabRow: {
+ flexDirection: "row",
+ paddingHorizontal: 48,
+ marginBottom: 16,
+ gap: 24,
+ },
+ tabButton: {
+ paddingVertical: 12,
+ paddingHorizontal: 20,
+ borderRadius: 8,
+ borderBottomWidth: 2,
+ },
+ tabText: {
+ fontSize: 18,
+ },
+});
+
export const Controls: FC = ({
item,
seek,
@@ -56,8 +491,92 @@ export const Controls: FC = ({
cacheProgress,
showControls,
setShowControls,
+ mediaSource,
+ audioIndex,
+ subtitleIndex,
+ onAudioIndexChange,
+ onSubtitleIndexChange,
}) => {
const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+
+ // Modal state for option selectors
+ // "settings" shows the settings panel, "audio"/"subtitle" for direct selection
+ type ModalType = "settings" | "audio" | "subtitle" | null;
+ const [openModal, setOpenModal] = useState(null);
+ const isModalOpen = openModal !== null;
+
+ // Handle swipe up to open settings panel
+ const handleSwipeUp = useCallback(() => {
+ if (!isModalOpen) {
+ setOpenModal("settings");
+ }
+ }, [isModalOpen]);
+
+ // Get available audio tracks
+ const audioTracks = useMemo(() => {
+ return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
+ }, [mediaSource]);
+
+ // Get available subtitle tracks
+ const subtitleTracks = useMemo(() => {
+ return (
+ mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? []
+ );
+ }, [mediaSource]);
+
+ // Audio options for selector
+ const audioOptions = useMemo(() => {
+ return audioTracks.map((track) => ({
+ label:
+ track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
+ value: track.Index!,
+ selected: track.Index === audioIndex,
+ }));
+ }, [audioTracks, audioIndex]);
+
+ // Subtitle options for selector (with "None" option)
+ const subtitleOptions = useMemo(() => {
+ const noneOption = {
+ label: t("item_card.subtitles.none"),
+ value: -1,
+ selected: subtitleIndex === -1,
+ };
+ const trackOptions = subtitleTracks.map((track) => ({
+ label:
+ track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
+ value: track.Index!,
+ selected: track.Index === subtitleIndex,
+ }));
+ return [noneOption, ...trackOptions];
+ }, [subtitleTracks, subtitleIndex, t]);
+
+ // Get display labels for buttons
+ const _selectedAudioLabel = useMemo(() => {
+ const track = audioTracks.find((t) => t.Index === audioIndex);
+ return track?.DisplayTitle || track?.Language || t("item_card.audio");
+ }, [audioTracks, audioIndex, t]);
+
+ const _selectedSubtitleLabel = useMemo(() => {
+ if (subtitleIndex === -1) return t("item_card.subtitles.none");
+ const track = subtitleTracks.find((t) => t.Index === subtitleIndex);
+ return track?.DisplayTitle || track?.Language || t("item_card.subtitles");
+ }, [subtitleTracks, subtitleIndex, t]);
+
+ // Handlers for option changes
+ const handleAudioChange = useCallback(
+ (index: number) => {
+ onAudioIndexChange?.(index);
+ },
+ [onAudioIndexChange],
+ );
+
+ const handleSubtitleChange = useCallback(
+ (index: number) => {
+ onSubtitleIndexChange?.(index);
+ },
+ [onSubtitleIndexChange],
+ );
const {
trickPlayUrl,
@@ -139,7 +658,6 @@ export const Controls: FC = ({
isRemoteScrubbing,
showRemoteBubble,
isSliding: isRemoteSliding,
- time: remoteTime,
} = useRemoteControl({
progress,
min,
@@ -153,6 +671,8 @@ export const Controls: FC = ({
calculateTrickplayUrl,
handleSeekForward,
handleSeekBackward,
+ disableSeeking: isModalOpen,
+ onSwipeUp: handleSwipeUp,
});
// Slider hook
@@ -217,6 +737,12 @@ export const Controls: FC = ({
disabled: false,
});
+ // Check if we have any settings to show
+ const hasSettings =
+ audioTracks.length > 0 ||
+ subtitleTracks.length > 0 ||
+ subtitleIndex !== undefined;
+
return (
{/* Center Play Button - shown when paused */}
@@ -228,9 +754,39 @@ export const Controls: FC = ({
)}
+ {/* Top hint - swipe up for settings */}
+ {showControls && hasSettings && !isModalOpen && (
+
+
+
+
+
+ {t("player.swipe_up_settings")}
+
+
+
+
+ )}
+
= ({
/>
- {/* Time Display - TV sized */}
+ {/* Time Display */}
{formatTimeString(currentTime, "ms")}
@@ -305,6 +861,34 @@ export const Controls: FC = ({
+
+ {/* Settings panel - shows audio and subtitle options */}
+ setOpenModal(null)}
+ t={t}
+ />
+
+ {/* Direct option selector modals (for future use) */}
+ setOpenModal(null)}
+ />
+
+ setOpenModal(null)}
+ />
);
};
@@ -335,6 +919,17 @@ const styles = StyleSheet.create({
alignItems: "center",
paddingLeft: 8,
},
+ topContainer: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ zIndex: 10,
+ },
+ topInner: {
+ flexDirection: "row",
+ justifyContent: "flex-end",
+ },
bottomContainer: {
position: "absolute",
bottom: 0,
@@ -368,10 +963,28 @@ const styles = StyleSheet.create({
timeContainer: {
flexDirection: "row",
justifyContent: "space-between",
+ alignItems: "center",
marginTop: 12,
},
timeText: {
color: "rgba(255,255,255,0.7)",
fontSize: 22,
},
+ settingsRow: {
+ flexDirection: "row",
+ gap: 12,
+ },
+ settingsHint: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ backgroundColor: "rgba(0,0,0,0.3)",
+ paddingVertical: 8,
+ paddingHorizontal: 16,
+ borderRadius: 20,
+ },
+ settingsHintText: {
+ color: "rgba(255,255,255,0.5)",
+ fontSize: 14,
+ },
});
diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts
index f1a7822f..b3e71886 100644
--- a/components/video-player/controls/hooks/useRemoteControl.ts
+++ b/components/video-player/controls/hooks/useRemoteControl.ts
@@ -31,6 +31,10 @@ interface UseRemoteControlProps {
calculateTrickplayUrl: (progressInTicks: number) => void;
handleSeekForward: (seconds: number) => void;
handleSeekBackward: (seconds: number) => void;
+ /** When true, disables left/right seeking (e.g., when settings modal is open) */
+ disableSeeking?: boolean;
+ /** Callback when swipe up is detected - used to open settings */
+ onSwipeUp?: () => void;
}
/**
@@ -50,6 +54,8 @@ export function useRemoteControl({
calculateTrickplayUrl,
handleSeekForward,
handleSeekBackward,
+ disableSeeking = false,
+ onSwipeUp,
}: UseRemoteControlProps) {
const remoteScrubProgress = useSharedValue(null);
const isRemoteScrubbing = useSharedValue(false);
@@ -63,6 +69,15 @@ export function useRemoteControl({
const longPressTimeoutRef = useRef | null>(
null,
);
+
+ // Use ref to track disableSeeking so the callback always has current value
+ const disableSeekingRef = useRef(disableSeeking);
+ disableSeekingRef.current = disableSeeking;
+
+ // Use ref for onSwipeUp callback
+ const onSwipeUpRef = useRef(onSwipeUp);
+ onSwipeUpRef.current = onSwipeUp;
+
// MPV uses ms
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
@@ -82,15 +97,21 @@ export function useRemoteControl({
switch (evt.eventType) {
case "longLeft": {
+ if (disableSeekingRef.current) break;
setLongPressScrubMode((prev) => (!prev ? "RW" : null));
break;
}
case "longRight": {
+ if (disableSeekingRef.current) break;
setLongPressScrubMode((prev) => (!prev ? "FF" : null));
break;
}
case "left":
case "right": {
+ // Skip seeking if disabled (e.g., when settings modal is open)
+ if (disableSeekingRef.current) {
+ break;
+ }
isRemoteScrubbing.value = true;
setShowRemoteBubble(true);
@@ -127,12 +148,18 @@ export function useRemoteControl({
break;
}
case "down":
- case "up":
- // cancel scrubbing on other directions
+ // cancel scrubbing on down
isRemoteScrubbing.value = false;
remoteScrubProgress.value = null;
setShowRemoteBubble(false);
break;
+ case "up":
+ // cancel scrubbing and trigger swipe up callback (for settings)
+ isRemoteScrubbing.value = false;
+ remoteScrubProgress.value = null;
+ setShowRemoteBubble(false);
+ onSwipeUpRef.current?.();
+ break;
default:
break;
}
diff --git a/translations/en.json b/translations/en.json
index 9a55dd80..fa44df98 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -611,7 +611,8 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
- "downloaded_file_cancel": "Cancel"
+ "downloaded_file_cancel": "Cancel",
+ "swipe_up_settings": "Swipe up for settings"
},
"item_card": {
"next_up": "Next Up",