mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-18 17:18:11 +00:00
wip
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user