mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 05:28:37 +01:00
wip
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -50,8 +50,6 @@ npm-debug.*
|
|||||||
.idea/
|
.idea/
|
||||||
.ruby-lsp
|
.ruby-lsp
|
||||||
.cursor/
|
.cursor/
|
||||||
.claude/
|
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
# Environment and Configuration
|
# Environment and Configuration
|
||||||
expo-env.d.ts
|
expo-env.d.ts
|
||||||
@@ -72,4 +70,5 @@ modules/background-downloader/android/build/*
|
|||||||
/modules/mpv-player/android/build
|
/modules/mpv-player/android/build
|
||||||
|
|
||||||
# ios:unsigned-build Artifacts
|
# 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 { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
@@ -24,6 +24,7 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { TV_POSTER_WIDTH } from "@/components/posters/MoviePoster.tv";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -49,6 +50,20 @@ import {
|
|||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
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 Page = () => {
|
||||||
const searchParams = useLocalSearchParams() as {
|
const searchParams = useLocalSearchParams() as {
|
||||||
libraryId: string;
|
libraryId: string;
|
||||||
@@ -162,6 +177,14 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
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 < 300) return 2;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 500) return 3;
|
||||||
if (screenWidth < 800) return 5;
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
{...props}
|
{...props}
|
||||||
className={`
|
style={[
|
||||||
rounded p-1 shrink grow-0 self-start flex flex-row items-center px-1.5
|
{
|
||||||
${variant === "purple" && "bg-purple-600"}
|
borderRadius: 4,
|
||||||
${variant === "gray" && "bg-neutral-800"}
|
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
|
<Text
|
||||||
className={`
|
style={{
|
||||||
text-xs
|
fontSize: 12,
|
||||||
${variant === "purple" && "text-white"}
|
color: "#fff",
|
||||||
`}
|
}}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -505,12 +505,10 @@ const TVOptionButton: React.FC<{
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: focused ? "#2a2a2a" : "rgba(255,255,255,0.1)",
|
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: focused ? "#fff" : "transparent",
|
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
@@ -519,7 +517,7 @@ const TVOptionButton: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: "#888",
|
color: focused ? "#444" : "#bbb",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -527,7 +525,7 @@ const TVOptionButton: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: "#FFFFFF",
|
color: focused ? "#000" : "#FFFFFF",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
@@ -640,7 +638,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
// Subtitle options for selector (with "None" option)
|
// Subtitle options for selector (with "None" option)
|
||||||
const subtitleOptions = useMemo(() => {
|
const subtitleOptions = useMemo(() => {
|
||||||
const noneOption = {
|
const noneOption = {
|
||||||
label: t("subtitles.none") || "None",
|
label: t("item_card.subtitles.none"),
|
||||||
value: -1,
|
value: -1,
|
||||||
selected: selectedOptions?.subtitleIndex === -1,
|
selected: selectedOptions?.subtitleIndex === -1,
|
||||||
};
|
};
|
||||||
@@ -729,7 +727,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
|
|
||||||
const selectedSubtitleLabel = useMemo(() => {
|
const selectedSubtitleLabel = useMemo(() => {
|
||||||
if (selectedOptions?.subtitleIndex === -1)
|
if (selectedOptions?.subtitleIndex === -1)
|
||||||
return t("subtitles.none") || "None";
|
return t("item_card.subtitles.none");
|
||||||
const track = subtitleTracks.find(
|
const track = subtitleTracks.find(
|
||||||
(t) => t.Index === selectedOptions?.subtitleIndex,
|
(t) => t.Index === selectedOptions?.subtitleIndex,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import { z } from "zod";
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVInput } from "@/components/login/TVInput";
|
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 { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle";
|
||||||
import { TVServerCard } from "@/components/login/TVServerCard";
|
import { TVServerCard } from "@/components/login/TVServerCard";
|
||||||
import { PasswordEntryModal } from "@/components/PasswordEntryModal";
|
import { PasswordEntryModal } from "@/components/PasswordEntryModal";
|
||||||
@@ -26,10 +29,11 @@ import { SaveAccountModal } from "@/components/SaveAccountModal";
|
|||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import type {
|
import {
|
||||||
AccountSecurityType,
|
type AccountSecurityType,
|
||||||
SavedServer,
|
removeServerFromList,
|
||||||
SavedServerAccount,
|
type SavedServer,
|
||||||
|
type SavedServerAccount,
|
||||||
} from "@/utils/secureCredentials";
|
} from "@/utils/secureCredentials";
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
@@ -84,6 +88,14 @@ export const TVLogin: React.FC = () => {
|
|||||||
const [selectedAccount, setSelectedAccount] =
|
const [selectedAccount, setSelectedAccount] =
|
||||||
useState<SavedServerAccount | null>(null);
|
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
|
// Server discovery
|
||||||
const {
|
const {
|
||||||
servers: discoveredServers,
|
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) => {
|
const checkUrl = useCallback(async (url: string) => {
|
||||||
setLoadingServerCheck(true);
|
setLoadingServerCheck(true);
|
||||||
const baseUrl = url.replace(/^https?:\/\//i, "");
|
const baseUrl = url.replace(/^https?:\/\//i, "");
|
||||||
@@ -593,6 +649,8 @@ export const TVLogin: React.FC = () => {
|
|||||||
onAddAccount={handleAddAccount}
|
onAddAccount={handleAddAccount}
|
||||||
onPinRequired={handlePinRequired}
|
onPinRequired={handlePinRequired}
|
||||||
onPasswordRequired={handlePasswordRequired}
|
onPasswordRequired={handlePasswordRequired}
|
||||||
|
onServerAction={handleServerAction}
|
||||||
|
loginServerOverride={loginTriggerServer}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -637,6 +695,16 @@ export const TVLogin: React.FC = () => {
|
|||||||
onSubmit={handlePasswordSubmit}
|
onSubmit={handlePasswordSubmit}
|
||||||
username={selectedAccount?.username || ""}
|
username={selectedAccount?.username || ""}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Server Action Sheet */}
|
||||||
|
<TVServerActionSheet
|
||||||
|
key={actionSheetKey}
|
||||||
|
visible={showServerActionSheet}
|
||||||
|
server={actionSheetServer}
|
||||||
|
onLogin={handleServerActionLogin}
|
||||||
|
onDelete={handleServerActionDelete}
|
||||||
|
onClose={() => setShowServerActionSheet(false)}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -18,7 +18,6 @@ import { Text } from "@/components/common/Text";
|
|||||||
import {
|
import {
|
||||||
deleteAccountCredential,
|
deleteAccountCredential,
|
||||||
getPreviousServers,
|
getPreviousServers,
|
||||||
removeServerFromList,
|
|
||||||
type SavedServer,
|
type SavedServer,
|
||||||
type SavedServerAccount,
|
type SavedServerAccount,
|
||||||
} from "@/utils/secureCredentials";
|
} from "@/utils/secureCredentials";
|
||||||
@@ -123,79 +122,86 @@ const TVServerActionSheet: React.FC<{
|
|||||||
}> = ({ visible, server, onLogin, onDelete, onClose }) => {
|
}> = ({ visible, server, onLogin, onDelete, onClose }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!visible || !server) return null;
|
if (!server) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<Modal
|
||||||
style={{
|
visible={visible}
|
||||||
position: "absolute",
|
transparent
|
||||||
top: 0,
|
animationType='fade'
|
||||||
left: 0,
|
onRequestClose={onClose}
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<BlurView
|
<View
|
||||||
intensity={80}
|
|
||||||
tint='dark'
|
|
||||||
style={{
|
style={{
|
||||||
borderTopLeftRadius: 24,
|
flex: 1,
|
||||||
borderTopRightRadius: 24,
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
overflow: "hidden",
|
justifyContent: "flex-end",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<BlurView
|
||||||
|
intensity={80}
|
||||||
|
tint='dark'
|
||||||
style={{
|
style={{
|
||||||
paddingTop: 24,
|
borderTopLeftRadius: 24,
|
||||||
paddingBottom: 50,
|
borderTopRightRadius: 24,
|
||||||
overflow: "visible",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Title */}
|
<View
|
||||||
<Text
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: 18,
|
paddingTop: 24,
|
||||||
fontWeight: "500",
|
paddingBottom: 50,
|
||||||
color: "rgba(255,255,255,0.6)",
|
overflow: "visible",
|
||||||
marginBottom: 8,
|
|
||||||
paddingHorizontal: 48,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{server.name || server.address}
|
{/* Title */}
|
||||||
</Text>
|
<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 */}
|
{/* Horizontal options */}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: 48,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
gap: 12,
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TVServerActionCard
|
<TVServerActionCard
|
||||||
label={t("common.login")}
|
label={t("common.login")}
|
||||||
icon='log-in-outline'
|
icon='log-in-outline'
|
||||||
hasTVPreferredFocus
|
hasTVPreferredFocus
|
||||||
onPress={onLogin}
|
onPress={onLogin}
|
||||||
/>
|
/>
|
||||||
<TVServerActionCard
|
<TVServerActionCard
|
||||||
label={t("common.delete")}
|
label={t("common.delete")}
|
||||||
icon='trash-outline'
|
icon='trash-outline'
|
||||||
variant='destructive'
|
variant='destructive'
|
||||||
onPress={onDelete}
|
onPress={onDelete}
|
||||||
/>
|
/>
|
||||||
</ScrollView>
|
<TVServerActionCard
|
||||||
</View>
|
label={t("common.cancel")}
|
||||||
</BlurView>
|
icon='close-outline'
|
||||||
</View>
|
onPress={onClose}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -213,14 +219,23 @@ interface TVPreviousServersListProps {
|
|||||||
server: SavedServer,
|
server: SavedServer,
|
||||||
account: SavedServerAccount,
|
account: SavedServerAccount,
|
||||||
) => void;
|
) => 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> = ({
|
export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
||||||
onServerSelect,
|
onServerSelect,
|
||||||
onQuickLogin,
|
onQuickLogin,
|
||||||
onAddAccount,
|
onAddAccount,
|
||||||
onPinRequired,
|
onPinRequired,
|
||||||
onPasswordRequired,
|
onPasswordRequired,
|
||||||
|
onServerAction,
|
||||||
|
loginServerOverride,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [_previousServers, setPreviousServers] =
|
const [_previousServers, setPreviousServers] =
|
||||||
@@ -230,12 +245,30 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [showAccountsModal, setShowAccountsModal] = useState(false);
|
const [showAccountsModal, setShowAccountsModal] = useState(false);
|
||||||
const [showActionSheet, setShowActionSheet] = useState(false);
|
|
||||||
|
|
||||||
const previousServers = useMemo(() => {
|
const previousServers = useMemo(() => {
|
||||||
return JSON.parse(_previousServers || "[]") as SavedServer[];
|
return JSON.parse(_previousServers || "[]") as SavedServer[];
|
||||||
}, [_previousServers]);
|
}, [_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 refreshServers = () => {
|
||||||
const servers = getPreviousServers();
|
const servers = getPreviousServers();
|
||||||
setPreviousServers(JSON.stringify(servers));
|
setPreviousServers(JSON.stringify(servers));
|
||||||
@@ -281,53 +314,25 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
|||||||
|
|
||||||
const handleServerPress = (server: SavedServer) => {
|
const handleServerPress = (server: SavedServer) => {
|
||||||
if (loadingServer) return;
|
if (loadingServer) return;
|
||||||
setSelectedServer(server);
|
|
||||||
setShowActionSheet(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleServerLoginAction = () => {
|
// If onServerAction is provided, delegate to parent for action sheet handling
|
||||||
if (!selectedServer) return;
|
if (onServerAction) {
|
||||||
setShowActionSheet(false);
|
onServerAction(server);
|
||||||
|
return;
|
||||||
const accountCount = selectedServer.accounts?.length || 0;
|
}
|
||||||
|
|
||||||
|
// Fallback: direct login flow (for backwards compatibility)
|
||||||
|
const accountCount = server.accounts?.length || 0;
|
||||||
if (accountCount === 0) {
|
if (accountCount === 0) {
|
||||||
onServerSelect(selectedServer);
|
onServerSelect(server);
|
||||||
} else if (accountCount === 1) {
|
} else if (accountCount === 1) {
|
||||||
handleAccountLogin(selectedServer, selectedServer.accounts[0]);
|
handleAccountLogin(server, server.accounts[0]);
|
||||||
} else {
|
} else {
|
||||||
|
setSelectedServer(server);
|
||||||
setShowAccountsModal(true);
|
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 getServerSubtitle = (server: SavedServer): string | undefined => {
|
||||||
const accountCount = server.accounts?.length || 0;
|
const accountCount = server.accounts?.length || 0;
|
||||||
|
|
||||||
@@ -498,15 +503,6 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* TV Server Action Sheet */}
|
|
||||||
<TVServerActionSheet
|
|
||||||
visible={showActionSheet}
|
|
||||||
server={selectedServer}
|
|
||||||
onLogin={handleServerLoginAction}
|
|
||||||
onDelete={handleServerDeleteAction}
|
|
||||||
onClose={() => setShowActionSheet(false)}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,24 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { type FC, useCallback, useEffect } from "react";
|
import { BlurView } from "expo-blur";
|
||||||
import { StyleSheet, View } from "react-native";
|
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 { Slider } from "react-native-awesome-slider";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -39,11 +55,430 @@ interface Props {
|
|||||||
seek: (ticks: number) => void;
|
seek: (ticks: number) => void;
|
||||||
play: () => void;
|
play: () => void;
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
|
audioIndex?: number;
|
||||||
|
subtitleIndex?: number;
|
||||||
|
onAudioIndexChange?: (index: number) => void;
|
||||||
|
onSubtitleIndexChange?: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TV_SEEKBAR_HEIGHT = 16;
|
const TV_SEEKBAR_HEIGHT = 16;
|
||||||
const TV_AUTO_HIDE_TIMEOUT = 5000;
|
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> = ({
|
export const Controls: FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
seek,
|
seek,
|
||||||
@@ -56,8 +491,92 @@ export const Controls: FC<Props> = ({
|
|||||||
cacheProgress,
|
cacheProgress,
|
||||||
showControls,
|
showControls,
|
||||||
setShowControls,
|
setShowControls,
|
||||||
|
mediaSource,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
onAudioIndexChange,
|
||||||
|
onSubtitleIndexChange,
|
||||||
}) => {
|
}) => {
|
||||||
const insets = useSafeAreaInsets();
|
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 {
|
const {
|
||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
@@ -139,7 +658,6 @@ export const Controls: FC<Props> = ({
|
|||||||
isRemoteScrubbing,
|
isRemoteScrubbing,
|
||||||
showRemoteBubble,
|
showRemoteBubble,
|
||||||
isSliding: isRemoteSliding,
|
isSliding: isRemoteSliding,
|
||||||
time: remoteTime,
|
|
||||||
} = useRemoteControl({
|
} = useRemoteControl({
|
||||||
progress,
|
progress,
|
||||||
min,
|
min,
|
||||||
@@ -153,6 +671,8 @@ export const Controls: FC<Props> = ({
|
|||||||
calculateTrickplayUrl,
|
calculateTrickplayUrl,
|
||||||
handleSeekForward,
|
handleSeekForward,
|
||||||
handleSeekBackward,
|
handleSeekBackward,
|
||||||
|
disableSeeking: isModalOpen,
|
||||||
|
onSwipeUp: handleSwipeUp,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Slider hook
|
// Slider hook
|
||||||
@@ -217,6 +737,12 @@ export const Controls: FC<Props> = ({
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if we have any settings to show
|
||||||
|
const hasSettings =
|
||||||
|
audioTracks.length > 0 ||
|
||||||
|
subtitleTracks.length > 0 ||
|
||||||
|
subtitleIndex !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.controlsContainer} pointerEvents='box-none'>
|
<View style={styles.controlsContainer} pointerEvents='box-none'>
|
||||||
{/* Center Play Button - shown when paused */}
|
{/* Center Play Button - shown when paused */}
|
||||||
@@ -228,9 +754,39 @@ export const Controls: FC<Props> = ({
|
|||||||
</View>
|
</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
|
<Animated.View
|
||||||
style={[styles.bottomContainer, bottomAnimatedStyle]}
|
style={[styles.bottomContainer, bottomAnimatedStyle]}
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
pointerEvents={showControls && !isModalOpen ? "auto" : "none"}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -294,7 +850,7 @@ export const Controls: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Time Display - TV sized */}
|
{/* Time Display */}
|
||||||
<View style={styles.timeContainer}>
|
<View style={styles.timeContainer}>
|
||||||
<Text style={styles.timeText}>
|
<Text style={styles.timeText}>
|
||||||
{formatTimeString(currentTime, "ms")}
|
{formatTimeString(currentTime, "ms")}
|
||||||
@@ -305,6 +861,34 @@ export const Controls: FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -335,6 +919,17 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingLeft: 8,
|
paddingLeft: 8,
|
||||||
},
|
},
|
||||||
|
topContainer: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
topInner: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
bottomContainer: {
|
bottomContainer: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
@@ -368,10 +963,28 @@ const styles = StyleSheet.create({
|
|||||||
timeContainer: {
|
timeContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
},
|
},
|
||||||
timeText: {
|
timeText: {
|
||||||
color: "rgba(255,255,255,0.7)",
|
color: "rgba(255,255,255,0.7)",
|
||||||
fontSize: 22,
|
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;
|
calculateTrickplayUrl: (progressInTicks: number) => void;
|
||||||
handleSeekForward: (seconds: number) => void;
|
handleSeekForward: (seconds: number) => void;
|
||||||
handleSeekBackward: (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,
|
calculateTrickplayUrl,
|
||||||
handleSeekForward,
|
handleSeekForward,
|
||||||
handleSeekBackward,
|
handleSeekBackward,
|
||||||
|
disableSeeking = false,
|
||||||
|
onSwipeUp,
|
||||||
}: UseRemoteControlProps) {
|
}: UseRemoteControlProps) {
|
||||||
const remoteScrubProgress = useSharedValue<number | null>(null);
|
const remoteScrubProgress = useSharedValue<number | null>(null);
|
||||||
const isRemoteScrubbing = useSharedValue(false);
|
const isRemoteScrubbing = useSharedValue(false);
|
||||||
@@ -63,6 +69,15 @@ export function useRemoteControl({
|
|||||||
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
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
|
// MPV uses ms
|
||||||
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
|
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
|
||||||
|
|
||||||
@@ -82,15 +97,21 @@ export function useRemoteControl({
|
|||||||
|
|
||||||
switch (evt.eventType) {
|
switch (evt.eventType) {
|
||||||
case "longLeft": {
|
case "longLeft": {
|
||||||
|
if (disableSeekingRef.current) break;
|
||||||
setLongPressScrubMode((prev) => (!prev ? "RW" : null));
|
setLongPressScrubMode((prev) => (!prev ? "RW" : null));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "longRight": {
|
case "longRight": {
|
||||||
|
if (disableSeekingRef.current) break;
|
||||||
setLongPressScrubMode((prev) => (!prev ? "FF" : null));
|
setLongPressScrubMode((prev) => (!prev ? "FF" : null));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "left":
|
case "left":
|
||||||
case "right": {
|
case "right": {
|
||||||
|
// Skip seeking if disabled (e.g., when settings modal is open)
|
||||||
|
if (disableSeekingRef.current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
isRemoteScrubbing.value = true;
|
isRemoteScrubbing.value = true;
|
||||||
setShowRemoteBubble(true);
|
setShowRemoteBubble(true);
|
||||||
|
|
||||||
@@ -127,12 +148,18 @@ export function useRemoteControl({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "down":
|
case "down":
|
||||||
case "up":
|
// cancel scrubbing on down
|
||||||
// cancel scrubbing on other directions
|
|
||||||
isRemoteScrubbing.value = false;
|
isRemoteScrubbing.value = false;
|
||||||
remoteScrubProgress.value = null;
|
remoteScrubProgress.value = null;
|
||||||
setShowRemoteBubble(false);
|
setShowRemoteBubble(false);
|
||||||
break;
|
break;
|
||||||
|
case "up":
|
||||||
|
// cancel scrubbing and trigger swipe up callback (for settings)
|
||||||
|
isRemoteScrubbing.value = false;
|
||||||
|
remoteScrubProgress.value = null;
|
||||||
|
setShowRemoteBubble(false);
|
||||||
|
onSwipeUpRef.current?.();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -611,7 +611,8 @@
|
|||||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
"downloaded_file_yes": "Yes",
|
"downloaded_file_yes": "Yes",
|
||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Cancel"
|
"downloaded_file_cancel": "Cancel",
|
||||||
|
"swipe_up_settings": "Swipe up for settings"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Next Up",
|
"next_up": "Next Up",
|
||||||
|
|||||||
Reference in New Issue
Block a user