This commit is contained in:
Fredrik Burmester
2026-01-16 14:48:08 +01:00
parent bdd284b9a6
commit a86df6c46b
9 changed files with 885 additions and 144 deletions

5
.gitignore vendored
View File

@@ -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/
build/
.claude/settings.local.json

View File

@@ -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 }) => (
<View style={{ marginTop: 12 }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
{item.ProductionYear}
</Text>
</View>
);
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;

View File

@@ -38,21 +38,37 @@ export const Badge: React.FC<Props> = ({
);
}
// On TV, use transparent backgrounds for a cleaner look
const isTV = Platform.isTV;
return (
<View
{...props}
className={`
rounded p-1 shrink grow-0 self-start flex flex-row items-center px-1.5
${variant === "purple" && "bg-purple-600"}
${variant === "gray" && "bg-neutral-800"}
`}
style={[
{
borderRadius: 4,
padding: 4,
paddingHorizontal: 6,
flexShrink: 1,
flexGrow: 0,
alignSelf: "flex-start",
flexDirection: "row",
alignItems: "center",
backgroundColor: isTV
? "rgba(255,255,255,0.1)"
: variant === "purple"
? "#9333ea"
: "#262626",
},
props.style,
]}
>
{iconLeft && <View className='mr-1'>{iconLeft}</View>}
{iconLeft && <View style={{ marginRight: 4 }}>{iconLeft}</View>}
<Text
className={`
text-xs
${variant === "purple" && "text-white"}
`}
style={{
fontSize: 12,
color: "#fff",
}}
>
{text}
</Text>

View File

@@ -505,12 +505,10 @@ const TVOptionButton: React.FC<{
>
<View
style={{
backgroundColor: focused ? "#2a2a2a" : "rgba(255,255,255,0.1)",
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
borderRadius: 10,
paddingVertical: 10,
paddingHorizontal: 16,
borderWidth: 2,
borderColor: focused ? "#fff" : "transparent",
flexDirection: "row",
alignItems: "center",
gap: 8,
@@ -519,7 +517,7 @@ const TVOptionButton: React.FC<{
<Text
style={{
fontSize: 14,
color: "#888",
color: focused ? "#444" : "#bbb",
}}
>
{label}
@@ -527,7 +525,7 @@ const TVOptionButton: React.FC<{
<Text
style={{
fontSize: 14,
color: "#FFFFFF",
color: focused ? "#000" : "#FFFFFF",
fontWeight: "500",
}}
numberOfLines={1}
@@ -640,7 +638,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = 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<ItemContentTVProps> = 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,
);

View File

@@ -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<SavedServerAccount | null>(null);
// Server action sheet state
const [showServerActionSheet, setShowServerActionSheet] = useState(false);
const [actionSheetServer, setActionSheetServer] =
useState<SavedServer | null>(null);
const [loginTriggerServer, setLoginTriggerServer] =
useState<SavedServer | null>(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}
/>
</View>
</View>
@@ -637,6 +695,16 @@ export const TVLogin: React.FC = () => {
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
{/* Server Action Sheet */}
<TVServerActionSheet
key={actionSheetKey}
visible={showServerActionSheet}
server={actionSheetServer}
onLogin={handleServerActionLogin}
onDelete={handleServerActionDelete}
onClose={() => setShowServerActionSheet(false)}
/>
</SafeAreaView>
);
};

View File

@@ -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 (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
<Modal
visible={visible}
transparent
animationType='fade'
onRequestClose={onClose}
>
<BlurView
intensity={80}
tint='dark'
<View
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
}}
>
<View
<BlurView
intensity={80}
tint='dark'
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
{/* Title */}
<Text
<View
style={{
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 8,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{server.name || server.address}
</Text>
{/* Title */}
<Text
style={{
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 8,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{server.name || server.address}
</Text>
{/* Horizontal options */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
<TVServerActionCard
label={t("common.login")}
icon='log-in-outline'
hasTVPreferredFocus
onPress={onLogin}
/>
<TVServerActionCard
label={t("common.delete")}
icon='trash-outline'
variant='destructive'
onPress={onDelete}
/>
</ScrollView>
</View>
</BlurView>
</View>
{/* Horizontal options */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
<TVServerActionCard
label={t("common.login")}
icon='log-in-outline'
hasTVPreferredFocus
onPress={onLogin}
/>
<TVServerActionCard
label={t("common.delete")}
icon='trash-outline'
variant='destructive'
onPress={onDelete}
/>
<TVServerActionCard
label={t("common.cancel")}
icon='close-outline'
onPress={onClose}
/>
</ScrollView>
</View>
</BlurView>
</View>
</Modal>
);
};
@@ -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<TVPreviousServersListProps> = ({
onServerSelect,
onQuickLogin,
onAddAccount,
onPinRequired,
onPasswordRequired,
onServerAction,
loginServerOverride,
}) => {
const { t } = useTranslation();
const [_previousServers, setPreviousServers] =
@@ -230,12 +245,30 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
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<TVPreviousServersListProps> = ({
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<TVPreviousServersListProps> = ({
</View>
</View>
</Modal>
{/* TV Server Action Sheet */}
<TVServerActionSheet
visible={showActionSheet}
server={selectedServer}
onLogin={handleServerLoginAction}
onDelete={handleServerDeleteAction}
onClose={() => setShowActionSheet(false)}
/>
</View>
);
};

View File

@@ -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<T> = {
label: string;
value: T;
selected: boolean;
};
// TV Option Selector - Bottom sheet with horizontal scrolling
const TVOptionSelector = <T,>({
visible,
title,
options,
onSelect,
onClose,
}: {
visible: boolean;
title: string;
options: TVOptionItem<T>[];
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 (
<View style={selectorStyles.overlay}>
<BlurView intensity={80} tint='dark' style={selectorStyles.blurContainer}>
<View style={selectorStyles.content}>
<Text style={selectorStyles.title}>{title}</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={selectorStyles.scrollView}
contentContainerStyle={selectorStyles.scrollContent}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</View>
</BlurView>
</View>
);
};
// 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 (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<RNAnimated.View
style={[
selectorStyles.card,
{
transform: [{ scale }],
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
},
]}
>
<Text
style={[
selectorStyles.cardText,
{ color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={2}
>
{label}
</Text>
{selected && !focused && (
<View style={selectorStyles.checkmark}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</RNAnimated.View>
</Pressable>
);
};
// Settings panel with tabs for Audio and Subtitles
const TVSettingsPanel: FC<{
visible: boolean;
audioOptions: TVOptionItem<number>[];
subtitleOptions: TVOptionItem<number>[];
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 (
<View style={selectorStyles.overlay}>
<BlurView intensity={80} tint='dark' style={selectorStyles.blurContainer}>
<View style={selectorStyles.content}>
{/* Tab buttons - no preferred focus, navigate here via up from options */}
<View style={selectorStyles.tabRow}>
{audioOptions.length > 0 && (
<TVSettingsTab
label={t("item_card.audio")}
active={activeTab === "audio"}
onPress={() => setActiveTab("audio")}
/>
)}
{subtitleOptions.length > 0 && (
<TVSettingsTab
label={t("item_card.subtitles")}
active={activeTab === "subtitle"}
onPress={() => setActiveTab("subtitle")}
/>
)}
</View>
{/* Options - first selected option gets preferred focus */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={selectorStyles.scrollView}
contentContainerStyle={selectorStyles.scrollContent}
>
{currentOptions.map((option, index) => (
<TVOptionCard
key={`${activeTab}-${index}`}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
currentOnSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</View>
</BlurView>
</View>
);
};
// 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 (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<RNAnimated.View
style={[
selectorStyles.tabButton,
{
transform: [{ scale }],
backgroundColor: focused
? "#fff"
: active
? "rgba(255,255,255,0.2)"
: "transparent",
borderBottomColor: active ? "#fff" : "transparent",
},
]}
>
<Text
style={[
selectorStyles.tabText,
{ color: focused ? "#000" : "#fff" },
(focused || active) && { fontWeight: "600" },
]}
>
{label}
</Text>
</RNAnimated.View>
</Pressable>
);
};
// 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 (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.08);
onFocusChange?.(true);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
onFocusChange?.(false);
}}
disabled={disabled}
focusable={!disabled}
>
<RNAnimated.View
style={[
selectorStyles.controlButton,
{
transform: [{ scale }],
backgroundColor: focused
? "rgba(255,255,255,0.25)"
: "rgba(255,255,255,0.15)",
borderColor: focused ? "rgba(255,255,255,0.6)" : "transparent",
},
]}
>
<Ionicons
name={icon}
size={20}
color='#fff'
style={{ marginRight: 6 }}
/>
<Text
style={[
selectorStyles.controlButtonText,
{ color: "#fff", fontWeight: focused ? "600" : "500" },
]}
numberOfLines={1}
>
{label}
</Text>
</RNAnimated.View>
</Pressable>
);
};
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<Props> = ({
item,
seek,
@@ -56,8 +491,92 @@ export const Controls: FC<Props> = ({
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<ModalType>(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<Props> = ({
isRemoteScrubbing,
showRemoteBubble,
isSliding: isRemoteSliding,
time: remoteTime,
} = useRemoteControl({
progress,
min,
@@ -153,6 +671,8 @@ export const Controls: FC<Props> = ({
calculateTrickplayUrl,
handleSeekForward,
handleSeekBackward,
disableSeeking: isModalOpen,
onSwipeUp: handleSwipeUp,
});
// Slider hook
@@ -217,6 +737,12 @@ export const Controls: FC<Props> = ({
disabled: false,
});
// Check if we have any settings to show
const hasSettings =
audioTracks.length > 0 ||
subtitleTracks.length > 0 ||
subtitleIndex !== undefined;
return (
<View style={styles.controlsContainer} pointerEvents='box-none'>
{/* Center Play Button - shown when paused */}
@@ -228,9 +754,39 @@ export const Controls: FC<Props> = ({
</View>
)}
{/* Top hint - swipe up for settings */}
{showControls && hasSettings && !isModalOpen && (
<Animated.View
style={[styles.topContainer, bottomAnimatedStyle]}
pointerEvents='none'
>
<View
style={[
styles.topInner,
{
paddingRight: Math.max(insets.right, 48),
paddingLeft: Math.max(insets.left, 48),
paddingTop: Math.max(insets.top, 48),
},
]}
>
<View style={styles.settingsHint}>
<Ionicons
name='chevron-up'
size={16}
color='rgba(255,255,255,0.5)'
/>
<Text style={styles.settingsHintText}>
{t("player.swipe_up_settings")}
</Text>
</View>
</View>
</Animated.View>
)}
<Animated.View
style={[styles.bottomContainer, bottomAnimatedStyle]}
pointerEvents={showControls ? "auto" : "none"}
pointerEvents={showControls && !isModalOpen ? "auto" : "none"}
>
<View
style={[
@@ -294,7 +850,7 @@ export const Controls: FC<Props> = ({
/>
</View>
{/* Time Display - TV sized */}
{/* Time Display */}
<View style={styles.timeContainer}>
<Text style={styles.timeText}>
{formatTimeString(currentTime, "ms")}
@@ -305,6 +861,34 @@ export const Controls: FC<Props> = ({
</View>
</View>
</Animated.View>
{/* Settings panel - shows audio and subtitle options */}
<TVSettingsPanel
visible={openModal === "settings"}
audioOptions={audioOptions}
subtitleOptions={subtitleOptions}
onAudioSelect={handleAudioChange}
onSubtitleSelect={handleSubtitleChange}
onClose={() => setOpenModal(null)}
t={t}
/>
{/* Direct option selector modals (for future use) */}
<TVOptionSelector
visible={openModal === "audio"}
title={t("item_card.audio")}
options={audioOptions}
onSelect={handleAudioChange}
onClose={() => setOpenModal(null)}
/>
<TVOptionSelector
visible={openModal === "subtitle"}
title={t("item_card.subtitles")}
options={subtitleOptions}
onSelect={handleSubtitleChange}
onClose={() => setOpenModal(null)}
/>
</View>
);
};
@@ -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,
},
});

View File

@@ -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<number | null>(null);
const isRemoteScrubbing = useSharedValue(false);
@@ -63,6 +69,15 @@ export function useRemoteControl({
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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;
}

View File

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