mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-09 23:48:41 +01:00
wip
This commit is contained in:
@@ -119,7 +119,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{!item.UserData?.Played && <WatchedIndicator item={item} />}
|
<WatchedIndicator item={item} />
|
||||||
<ProgressBar item={item} />
|
<ProgressBar item={item} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,37 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
|
|
||||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
// TV: Show white checkmark when watched
|
||||||
|
if (
|
||||||
|
item.UserData?.Played &&
|
||||||
|
(item.Type === "Movie" || item.Type === "Episode")
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.9)",
|
||||||
|
borderRadius: 14,
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='checkmark' size={18} color='black' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile: Show purple triangle for unwatched
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{item.UserData?.Played === false &&
|
{item.UserData?.Played === false &&
|
||||||
|
|||||||
141
components/inputs/TVPinInput.tsx
Normal file
141
components/inputs/TVPinInput.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
type TextInputProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface TVPinInputProps
|
||||||
|
extends Omit<TextInputProps, "value" | "onChangeText" | "style"> {
|
||||||
|
value: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
length?: number;
|
||||||
|
label?: string;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TVPinInputRef {
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVPinInputComponent = React.forwardRef<TVPinInputRef, TVPinInputProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
length = 4,
|
||||||
|
label,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
placeholder,
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
React.useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
focus: () => inputRef.current?.focus(),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.02 : 1,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeText = (text: string) => {
|
||||||
|
// Only allow numeric input and limit to length
|
||||||
|
const numericText = text.replace(/[^0-9]/g, "").slice(0, length);
|
||||||
|
onChangeText(numericText);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => inputRef.current?.focus()}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
transform: [{ scale }],
|
||||||
|
borderColor: isFocused ? "#6366F1" : "#374151",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label && <Text style={styles.label}>{label}</Text>}
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
onChangeText={handleChangeText}
|
||||||
|
keyboardType='number-pad'
|
||||||
|
maxLength={length}
|
||||||
|
secureTextEntry
|
||||||
|
placeholder={placeholder || `Enter ${length}-digit PIN`}
|
||||||
|
placeholderTextColor='#6B7280'
|
||||||
|
style={styles.input}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
TVPinInputComponent.displayName = "TVPinInput";
|
||||||
|
|
||||||
|
export const TVPinInput = TVPinInputComponent;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: "#1F2937",
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 2,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 4,
|
||||||
|
minWidth: 280,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 4,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
fontSize: 24,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "500",
|
||||||
|
textAlign: "center",
|
||||||
|
height: 56,
|
||||||
|
letterSpacing: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -10,12 +10,14 @@ import {
|
|||||||
interface TVInputProps extends TextInputProps {
|
interface TVInputProps extends TextInputProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
hasTVPreferredFocus?: boolean;
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TVInput: React.FC<TVInputProps> = ({
|
export const TVInput: React.FC<TVInputProps> = ({
|
||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
hasTVPreferredFocus,
|
hasTVPreferredFocus,
|
||||||
|
disabled = false,
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
@@ -49,7 +51,9 @@ export const TVInput: React.FC<TVInputProps> = ({
|
|||||||
onPress={() => inputRef.current?.focus()}
|
onPress={() => inputRef.current?.focus()}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -19,17 +19,15 @@ 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 { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
|
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
||||||
import {
|
import {
|
||||||
TVPreviousServersList,
|
TVPreviousServersList,
|
||||||
TVServerActionSheet,
|
TVServerActionSheet,
|
||||||
} from "@/components/login/TVPreviousServersList";
|
} from "@/components/login/TVPreviousServersList";
|
||||||
|
import { TVSaveAccountModal } from "@/components/login/TVSaveAccountModal";
|
||||||
import { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle";
|
import { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle";
|
||||||
import { TVServerCard } from "@/components/login/TVServerCard";
|
|
||||||
import { PasswordEntryModal } from "@/components/PasswordEntryModal";
|
|
||||||
import { PINEntryModal } from "@/components/PINEntryModal";
|
|
||||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
type AccountSecurityType,
|
type AccountSecurityType,
|
||||||
@@ -42,10 +40,11 @@ const CredentialsSchema = z.object({
|
|||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const TVBackButton: React.FC<{ onPress: () => void; label: string }> = ({
|
const TVBackButton: React.FC<{
|
||||||
onPress,
|
onPress: () => void;
|
||||||
label,
|
label: string;
|
||||||
}) => {
|
disabled?: boolean;
|
||||||
|
}> = ({ onPress, label, disabled = false }) => {
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
@@ -70,6 +69,8 @@ const TVBackButton: React.FC<{ onPress: () => void; label: string }> = ({
|
|||||||
animateFocus(false);
|
animateFocus(false);
|
||||||
}}
|
}}
|
||||||
style={{ alignSelf: "flex-start", marginBottom: 40 }}
|
style={{ alignSelf: "flex-start", marginBottom: 40 }}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
@@ -161,12 +162,12 @@ export const TVLogin: React.FC = () => {
|
|||||||
useState<SavedServer | null>(null);
|
useState<SavedServer | null>(null);
|
||||||
const [actionSheetKey, setActionSheetKey] = useState(0);
|
const [actionSheetKey, setActionSheetKey] = useState(0);
|
||||||
|
|
||||||
// Server discovery
|
// Track if any modal is open to disable background focus
|
||||||
const {
|
const isAnyModalOpen =
|
||||||
servers: discoveredServers,
|
showSaveModal ||
|
||||||
isSearching,
|
pinModalVisible ||
|
||||||
startDiscovery,
|
passwordModalVisible ||
|
||||||
} = useJellyfinDiscovery();
|
showServerActionSheet;
|
||||||
|
|
||||||
// Auto login from URL params
|
// Auto login from URL params
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -470,6 +471,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
<TVBackButton
|
<TVBackButton
|
||||||
onPress={() => removeServer()}
|
onPress={() => removeServer()}
|
||||||
label={t("login.change_server")}
|
label={t("login.change_server")}
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
@@ -513,6 +515,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
textContentType='username'
|
textContentType='username'
|
||||||
returnKeyType='next'
|
returnKeyType='next'
|
||||||
hasTVPreferredFocus
|
hasTVPreferredFocus
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -528,6 +531,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='password'
|
textContentType='password'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -537,6 +541,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
value={saveAccount}
|
value={saveAccount}
|
||||||
onValueChange={setSaveAccount}
|
onValueChange={setSaveAccount}
|
||||||
label={t("save_account.save_for_later")}
|
label={t("save_account.save_for_later")}
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -623,11 +628,12 @@ export const TVLogin: React.FC = () => {
|
|||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
hasTVPreferredFocus
|
hasTVPreferredFocus
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Connect Button */}
|
{/* Connect Button */}
|
||||||
<View style={{ marginBottom: 16 }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => handleConnect(serverURL)}
|
onPress={() => handleConnect(serverURL)}
|
||||||
loading={loadingServerCheck}
|
loading={loadingServerCheck}
|
||||||
@@ -637,59 +643,6 @@ export const TVLogin: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Server Discovery */}
|
|
||||||
<View style={{ marginBottom: 24 }}>
|
|
||||||
<Button
|
|
||||||
onPress={startDiscovery}
|
|
||||||
color='black'
|
|
||||||
className='bg-neutral-800'
|
|
||||||
>
|
|
||||||
{isSearching
|
|
||||||
? t("server.searching")
|
|
||||||
: t("server.search_for_local_servers")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Discovered Servers */}
|
|
||||||
{discoveredServers.length > 0 && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
marginTop: 16,
|
|
||||||
marginBottom: 16,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: "600",
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("server.servers")}
|
|
||||||
</Text>
|
|
||||||
<View style={{ gap: 16 }}>
|
|
||||||
{discoveredServers.map((server) => (
|
|
||||||
<TVServerCard
|
|
||||||
key={server.address}
|
|
||||||
title={server.serverName || server.address}
|
|
||||||
subtitle={
|
|
||||||
server.serverName ? server.address : undefined
|
|
||||||
}
|
|
||||||
onPress={() => {
|
|
||||||
setServerURL(server.address);
|
|
||||||
if (server.serverName) {
|
|
||||||
setServerName(server.serverName);
|
|
||||||
}
|
|
||||||
handleConnect(server.address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Previous Servers */}
|
{/* Previous Servers */}
|
||||||
<View style={{ paddingHorizontal: 8 }}>
|
<View style={{ paddingHorizontal: 8 }}>
|
||||||
<TVPreviousServersList
|
<TVPreviousServersList
|
||||||
@@ -701,6 +654,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
onPasswordRequired={handlePasswordRequired}
|
onPasswordRequired={handlePasswordRequired}
|
||||||
onServerAction={handleServerAction}
|
onServerAction={handleServerAction}
|
||||||
loginServerOverride={loginTriggerServer}
|
loginServerOverride={loginTriggerServer}
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -709,7 +663,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
{/* Save Account Modal */}
|
{/* Save Account Modal */}
|
||||||
<SaveAccountModal
|
<TVSaveAccountModal
|
||||||
visible={showSaveModal}
|
visible={showSaveModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowSaveModal(false);
|
setShowSaveModal(false);
|
||||||
@@ -720,7 +674,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* PIN Entry Modal */}
|
{/* PIN Entry Modal */}
|
||||||
<PINEntryModal
|
<TVPINEntryModal
|
||||||
visible={pinModalVisible}
|
visible={pinModalVisible}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setPinModalVisible(false);
|
setPinModalVisible(false);
|
||||||
@@ -735,7 +689,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Password Entry Modal */}
|
{/* Password Entry Modal */}
|
||||||
<PasswordEntryModal
|
<TVPasswordEntryModal
|
||||||
visible={passwordModalVisible}
|
visible={passwordModalVisible}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setPasswordModalVisible(false);
|
setPasswordModalVisible(false);
|
||||||
|
|||||||
327
components/login/TVPINEntryModal.tsx
Normal file
327
components/login/TVPINEntryModal.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv";
|
||||||
|
import { verifyAccountPIN } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
|
interface TVPINEntryModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onForgotPIN?: () => void;
|
||||||
|
serverUrl: string;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forgot PIN Button
|
||||||
|
const TVForgotPINButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}> = ({ onPress, label, hasTVPreferredFocus = false }) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: focused
|
||||||
|
? "rgba(168, 85, 247, 0.2)"
|
||||||
|
: "transparent",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused ? "#d8b4fe" : "#a855f7",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
onForgotPIN,
|
||||||
|
serverUrl,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [pinCode, setPinCode] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
|
const pinInputRef = useRef<TVPinInputRef>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
const shakeAnimation = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// Reset state when opening
|
||||||
|
setPinCode("");
|
||||||
|
setError(null);
|
||||||
|
setIsVerifying(false);
|
||||||
|
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
setIsReady(false);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && isReady) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pinInputRef.current?.focus();
|
||||||
|
}, 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [visible, isReady]);
|
||||||
|
|
||||||
|
const shake = () => {
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: 15,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: -15,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: 15,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinChange = async (value: string) => {
|
||||||
|
setPinCode(value);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Auto-verify when 4 digits entered
|
||||||
|
if (value.length === 4) {
|
||||||
|
setIsVerifying(true);
|
||||||
|
try {
|
||||||
|
const isValid = await verifyAccountPIN(serverUrl, userId, value);
|
||||||
|
if (isValid) {
|
||||||
|
onSuccess();
|
||||||
|
setPinCode("");
|
||||||
|
} else {
|
||||||
|
setError(t("pin.invalid_pin"));
|
||||||
|
shake();
|
||||||
|
setPinCode("");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(t("pin.invalid_pin"));
|
||||||
|
shake();
|
||||||
|
setPinCode("");
|
||||||
|
} finally {
|
||||||
|
setIsVerifying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForgotPIN = () => {
|
||||||
|
Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
|
||||||
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("common.continue"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
onClose();
|
||||||
|
onForgotPIN?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("pin.enter_pin")}</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{t("pin.enter_pin_for", { username })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* PIN Input */}
|
||||||
|
{isReady && (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.pinContainer,
|
||||||
|
{ transform: [{ translateX: shakeAnimation }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TVPinInput
|
||||||
|
ref={pinInputRef}
|
||||||
|
value={pinCode}
|
||||||
|
onChangeText={handlePinChange}
|
||||||
|
length={4}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
{isVerifying && (
|
||||||
|
<Text style={styles.verifyingText}>
|
||||||
|
{t("common.verifying")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Forgot PIN */}
|
||||||
|
{isReady && onForgotPIN && (
|
||||||
|
<View style={styles.forgotContainer}>
|
||||||
|
<TVForgotPINButton
|
||||||
|
onPress={handleForgotPIN}
|
||||||
|
label={t("pin.forgot_pin")}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
},
|
||||||
|
pinContainer: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#ef4444",
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
verifyingText: {
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
forgotContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
337
components/login/TVPasswordEntryModal.tsx
Normal file
337
components/login/TVPasswordEntryModal.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv";
|
||||||
|
|
||||||
|
interface TVPasswordEntryModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (password: string) => Promise<void>;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV Submit Button
|
||||||
|
const TVSubmitButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ onPress, label, loading = false, disabled = false }) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
|
const isDisabled = disabled || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={isDisabled}
|
||||||
|
focusable={!isDisabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#a855f7"
|
||||||
|
: isDisabled
|
||||||
|
? "#4a4a4a"
|
||||||
|
: "#7c3aed",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
minWidth: 120,
|
||||||
|
opacity: isDisabled ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size='small' color='#fff' />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name='log-in-outline' size={20} color='#fff' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TV Focusable Password Input
|
||||||
|
const TVPasswordInput: React.FC<{
|
||||||
|
value: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
onSubmitEditing: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}> = ({
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
placeholder,
|
||||||
|
onSubmitEditing,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
}) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => inputRef.current?.focus()}
|
||||||
|
onFocus={() => {
|
||||||
|
handleFocus();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: "#1F2937",
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: focused ? "#6366F1" : "#374151",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor='#6B7280'
|
||||||
|
secureTextEntry
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 18,
|
||||||
|
}}
|
||||||
|
onSubmitEditing={onSubmitEditing}
|
||||||
|
returnKeyType='done'
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
username,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// Reset state when opening
|
||||||
|
setPassword("");
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
setIsReady(false);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!password) {
|
||||||
|
setError(t("password.enter_password"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(password);
|
||||||
|
setPassword("");
|
||||||
|
} catch {
|
||||||
|
setError(t("password.invalid_password"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("password.enter_password")}</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{t("password.enter_password_for", { username })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.inputLabel}>{t("login.password")}</Text>
|
||||||
|
<TVPasswordInput
|
||||||
|
value={password}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setPassword(text);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder={t("login.password")}
|
||||||
|
onSubmitEditing={handleSubmit}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
/>
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TVSubmitButton
|
||||||
|
onPress={handleSubmit}
|
||||||
|
label={t("login.login")}
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={!password}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
},
|
||||||
|
inputContainer: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
inputLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#ef4444",
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -223,6 +223,8 @@ interface TVPreviousServersListProps {
|
|||||||
onServerAction?: (server: SavedServer) => void;
|
onServerAction?: (server: SavedServer) => void;
|
||||||
// Called by parent when "Login" is selected from action sheet
|
// Called by parent when "Login" is selected from action sheet
|
||||||
loginServerOverride?: SavedServer | null;
|
loginServerOverride?: SavedServer | null;
|
||||||
|
// Disable all focusable elements (when a modal is open)
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export the action sheet for use in parent components
|
// Export the action sheet for use in parent components
|
||||||
@@ -236,6 +238,7 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
|||||||
onPasswordRequired,
|
onPasswordRequired,
|
||||||
onServerAction,
|
onServerAction,
|
||||||
loginServerOverride,
|
loginServerOverride,
|
||||||
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [_previousServers, setPreviousServers] =
|
const [_previousServers, setPreviousServers] =
|
||||||
@@ -416,6 +419,7 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
|||||||
securityIcon={getSecurityIcon(server)}
|
securityIcon={getSecurityIcon(server)}
|
||||||
isLoading={loadingServer === server.address}
|
isLoading={loadingServer === server.address}
|
||||||
onPress={() => handleServerPress(server)}
|
onPress={() => handleServerPress(server)}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
435
components/login/TVSaveAccountModal.tsx
Normal file
435
components/login/TVSaveAccountModal.tsx
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput";
|
||||||
|
import { TVOptionCard, useTVFocusAnimation } from "@/components/tv";
|
||||||
|
import type { AccountSecurityType } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
|
interface TVSaveAccountModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (securityType: AccountSecurityType, pinCode?: string) => void;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecurityOption {
|
||||||
|
type: AccountSecurityType;
|
||||||
|
titleKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECURITY_OPTIONS: SecurityOption[] = [
|
||||||
|
{
|
||||||
|
type: "none",
|
||||||
|
titleKey: "save_account.no_protection",
|
||||||
|
descriptionKey: "save_account.no_protection_desc",
|
||||||
|
icon: "flash-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pin",
|
||||||
|
titleKey: "save_account.pin_code",
|
||||||
|
descriptionKey: "save_account.pin_code_desc",
|
||||||
|
icon: "keypad-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "password",
|
||||||
|
titleKey: "save_account.password",
|
||||||
|
descriptionKey: "save_account.password_desc",
|
||||||
|
icon: "lock-closed-outline",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Custom Save Button with TV focus
|
||||||
|
const TVSaveButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}> = ({ onPress, label, disabled = false, hasTVPreferredFocus = false }) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#a855f7"
|
||||||
|
: disabled
|
||||||
|
? "#4a4a4a"
|
||||||
|
: "#7c3aed",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name='checkmark' size={20} color='#fff' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Back Button for PIN step
|
||||||
|
const TVBackButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}> = ({ onPress, label, hasTVPreferredFocus = false }) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-back'
|
||||||
|
size={20}
|
||||||
|
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused ? "#000" : "rgba(255,255,255,0.8)",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVSaveAccountModal: React.FC<TVSaveAccountModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
username,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [step, setStep] = useState<"select" | "pin">("select");
|
||||||
|
const [selectedType, setSelectedType] = useState<AccountSecurityType>("none");
|
||||||
|
const [pinCode, setPinCode] = useState("");
|
||||||
|
const [pinError, setPinError] = useState<string | null>(null);
|
||||||
|
const pinInputRef = useRef<TVPinInputRef>(null);
|
||||||
|
|
||||||
|
// Use useState for focus tracking (per TV focus guide)
|
||||||
|
const [firstCardRef, setFirstCardRef] = useState<View | null>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// Reset state when opening
|
||||||
|
setStep("select");
|
||||||
|
setSelectedType("none");
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
setIsReady(false);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// Focus the first card when ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && firstCardRef && step === "select") {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
(firstCardRef as any)?.requestTVFocus?.();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isReady, firstCardRef, step]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === "pin" && isReady) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pinInputRef.current?.focus();
|
||||||
|
}, 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [step, isReady]);
|
||||||
|
|
||||||
|
const handleOptionSelect = (type: AccountSecurityType) => {
|
||||||
|
setSelectedType(type);
|
||||||
|
if (type === "pin") {
|
||||||
|
setStep("pin");
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
} else {
|
||||||
|
// For "none" or "password", save immediately
|
||||||
|
onSave(type);
|
||||||
|
resetAndClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinSave = () => {
|
||||||
|
if (pinCode.length !== 4) {
|
||||||
|
setPinError(t("pin.enter_4_digits"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave("pin", pinCode);
|
||||||
|
resetAndClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep("select");
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAndClose = () => {
|
||||||
|
setStep("select");
|
||||||
|
setSelectedType("none");
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("save_account.title")}</Text>
|
||||||
|
<Text style={styles.subtitle}>{username}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{step === "select" ? (
|
||||||
|
// Security selection step
|
||||||
|
<>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
{t("save_account.security_option")}
|
||||||
|
</Text>
|
||||||
|
{isReady && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{SECURITY_OPTIONS.map((option, index) => (
|
||||||
|
<TVOptionCard
|
||||||
|
key={option.type}
|
||||||
|
ref={index === 0 ? setFirstCardRef : undefined}
|
||||||
|
label={t(option.titleKey)}
|
||||||
|
sublabel={t(option.descriptionKey)}
|
||||||
|
selected={selectedType === option.type}
|
||||||
|
hasTVPreferredFocus={index === 0}
|
||||||
|
onPress={() => handleOptionSelect(option.type)}
|
||||||
|
width={220}
|
||||||
|
height={100}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// PIN entry step
|
||||||
|
<>
|
||||||
|
<Text style={styles.sectionTitle}>{t("pin.setup_pin")}</Text>
|
||||||
|
<View style={styles.pinContainer}>
|
||||||
|
<TVPinInput
|
||||||
|
ref={pinInputRef}
|
||||||
|
value={pinCode}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setPinCode(text);
|
||||||
|
setPinError(null);
|
||||||
|
}}
|
||||||
|
length={4}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{pinError && <Text style={styles.errorText}>{pinError}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
<TVBackButton
|
||||||
|
onPress={handleBack}
|
||||||
|
label={t("common.back")}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
/>
|
||||||
|
<TVSaveButton
|
||||||
|
onPress={handlePinSave}
|
||||||
|
label={t("save_account.save_button")}
|
||||||
|
disabled={pinCode.length !== 4}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
marginTop: 20,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
pinContainer: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#ef4444",
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 12,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ interface TVSaveAccountToggleProps {
|
|||||||
onValueChange: (value: boolean) => void;
|
onValueChange: (value: boolean) => void;
|
||||||
label: string;
|
label: string;
|
||||||
hasTVPreferredFocus?: boolean;
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
||||||
@@ -15,6 +16,7 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
|||||||
onValueChange,
|
onValueChange,
|
||||||
label,
|
label,
|
||||||
hasTVPreferredFocus,
|
hasTVPreferredFocus,
|
||||||
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
@@ -52,7 +54,9 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
|||||||
onPress={() => onValueChange(!value)}
|
onPress={() => onValueChange(!value)}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface TVServerCardProps {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
hasTVPreferredFocus?: boolean;
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TVServerCard: React.FC<TVServerCardProps> = ({
|
export const TVServerCard: React.FC<TVServerCardProps> = ({
|
||||||
@@ -26,6 +27,7 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
onPress,
|
onPress,
|
||||||
hasTVPreferredFocus,
|
hasTVPreferredFocus,
|
||||||
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
@@ -58,13 +60,16 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
|||||||
animateFocus(false);
|
animateFocus(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDisabled = disabled || isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
disabled={isLoading}
|
disabled={isDisabled}
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
focusable={!isDisabled}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !isDisabled}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!episode.UserData?.Played && <WatchedIndicator item={episode} />}
|
<WatchedIndicator item={episode} />
|
||||||
<ProgressBar item={episode} />
|
<ProgressBar item={episode} />
|
||||||
</View>
|
</View>
|
||||||
</TVFocusablePoster>
|
</TVFocusablePoster>
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
|
|
||||||
HDR content appears washed out on Apple TV when using the mpv-based player. The TV doesn't show an HDR indicator and colors look flat compared to other apps like Infuse.
|
HDR content appears washed out on Apple TV when using the mpv-based player. The TV doesn't show an HDR indicator and colors look flat compared to other apps like Infuse.
|
||||||
|
|
||||||
## Current Implementation
|
**Key Discovery:** HDR works correctly on iPhone but not on tvOS, despite using the same mpv player.
|
||||||
|
|
||||||
Streamyfin uses MPVKit with the `vo_avfoundation` video output driver, which renders video to `AVSampleBufferDisplayLayer`. This enables Picture-in-Picture (PiP) support but has HDR limitations.
|
---
|
||||||
|
|
||||||
**Current code in `MpvPlayerView.swift`:**
|
## Why HDR Works on iPhone
|
||||||
|
|
||||||
|
In `MpvPlayerView.swift`:
|
||||||
```swift
|
```swift
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
@@ -17,141 +19,392 @@ if #available(iOS 17.0, *) {
|
|||||||
#endif
|
#endif
|
||||||
```
|
```
|
||||||
|
|
||||||
The tvOS exclusion was intentional because `wantsExtendedDynamicRangeContent` was believed to be iOS-only, but this may not be accurate for tvOS 17.0+.
|
On iOS 17+, setting `wantsExtendedDynamicRangeContent = true` on `AVSampleBufferDisplayLayer` enables Extended Dynamic Range (EDR). This tells the display layer to preserve HDR metadata and render in high dynamic range.
|
||||||
|
|
||||||
|
**This API does not exist on tvOS.** Attempting to use it results in:
|
||||||
|
> 'wantsExtendedDynamicRangeContent' is unavailable in tvOS
|
||||||
|
|
||||||
|
tvOS uses a different HDR architecture designed for external displays via HDMI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Research Findings
|
## tvOS HDR Architecture
|
||||||
|
|
||||||
### 1. This is a Known Industry-Wide Limitation
|
Unlike iPhone (integrated display), Apple TV connects to external TVs. Apple expects apps to:
|
||||||
|
|
||||||
The same HDR issue exists in multiple projects:
|
1. **Use `AVDisplayCriteria`** to request display mode changes
|
||||||
|
2. **Attach proper colorspace metadata** to pixel buffers
|
||||||
|
3. **Let the TV handle HDR rendering** via HDMI passthrough
|
||||||
|
|
||||||
|
This is how Netflix, Infuse, and the TV app work - they signal "I'm playing HDR10 at 24fps" and tvOS switches the TV to that mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MPVKit vo_avfoundation Analysis
|
||||||
|
|
||||||
|
**Location:** `/MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch`
|
||||||
|
|
||||||
|
### Existing HDR Infrastructure
|
||||||
|
|
||||||
|
The driver has comprehensive HDR support already built in:
|
||||||
|
|
||||||
|
#### 1. HDR Metadata Copy Function (lines 253-270)
|
||||||
|
```c
|
||||||
|
static void copy_hdr_metadata(CVPixelBufferRef src, CVPixelBufferRef dst)
|
||||||
|
{
|
||||||
|
const CFStringRef keys[] = {
|
||||||
|
kCVImageBufferTransferFunctionKey, // PQ for HDR10, HLG for HLG
|
||||||
|
kCVImageBufferColorPrimariesKey, // BT.2020 for HDR
|
||||||
|
kCVImageBufferYCbCrMatrixKey,
|
||||||
|
kCVImageBufferMasteringDisplayColorVolumeKey, // HDR10 static metadata
|
||||||
|
kCVImageBufferContentLightLevelInfoKey, // MaxCLL, MaxFALL
|
||||||
|
};
|
||||||
|
|
||||||
|
for (size_t i = 0; i < MP_ARRAY_SIZE(keys); i++) {
|
||||||
|
CFTypeRef value = CVBufferGetAttachment(src, keys[i], NULL);
|
||||||
|
if (value) {
|
||||||
|
CVBufferSetAttachment(dst, keys[i], value, kCVAttachmentMode_ShouldPropagate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 10-bit HDR Format Support (lines 232-247)
|
||||||
|
```c
|
||||||
|
// For 10-bit HDR content (P010), use RGBA half-float to preserve HDR precision
|
||||||
|
if (format == kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange ||
|
||||||
|
format == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange) {
|
||||||
|
outputFormat = kCVPixelFormatType_64RGBAHalf;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. HDR-Safe GPU Compositing (lines 694-695)
|
||||||
|
```c
|
||||||
|
CGColorSpaceRef workingColorSpace = CGColorSpaceCreateWithName(
|
||||||
|
kCGColorSpaceExtendedLinearDisplayP3);
|
||||||
|
```
|
||||||
|
|
||||||
|
### The Problem: Metadata Not Attached in Main Code Path
|
||||||
|
|
||||||
|
**Critical Finding:** `copy_hdr_metadata()` is only called during OSD compositing (line 609-610):
|
||||||
|
|
||||||
|
```c
|
||||||
|
// In composite mode, render OSD and composite onto frame
|
||||||
|
if (p->composite_osd) {
|
||||||
|
render_osd(vo, pts);
|
||||||
|
CVPixelBufferRef composited = composite_frame(vo, pixbuf);
|
||||||
|
// copy_hdr_metadata() called inside composite_frame()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `composite_osd` is false (default), **HDR metadata is never attached**.
|
||||||
|
|
||||||
|
### Frame Flow Analysis
|
||||||
|
|
||||||
|
```
|
||||||
|
draw_frame() called
|
||||||
|
│
|
||||||
|
├─► Hardware decoded (IMGFMT_VIDEOTOOLBOX)
|
||||||
|
│ └─► pixbuf = mpi->planes[3] // Direct from VideoToolbox
|
||||||
|
│ └─► Metadata SHOULD be attached by decoder, but not verified
|
||||||
|
│
|
||||||
|
└─► Software decoded (NV12, 420P, P010)
|
||||||
|
└─► upload_software_frame()
|
||||||
|
└─► Creates new CVPixelBuffer
|
||||||
|
└─► Copies pixel data only
|
||||||
|
└─► ❌ NO colorspace metadata attached!
|
||||||
|
|
||||||
|
▼
|
||||||
|
CMVideoFormatDescriptionCreateForImageBuffer(finalBuffer)
|
||||||
|
└─► Format description created FROM pixel buffer
|
||||||
|
└─► If buffer lacks HDR metadata, format won't have it
|
||||||
|
|
||||||
|
▼
|
||||||
|
[displayLayer enqueueSampleBuffer:buf]
|
||||||
|
└─► Sent to display layer without HDR signal
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root Cause Summary
|
||||||
|
|
||||||
|
| Issue | Impact |
|
||||||
|
|-------|--------|
|
||||||
|
| `wantsExtendedDynamicRangeContent` unavailable on tvOS | Can't use iOS EDR approach |
|
||||||
|
| `copy_hdr_metadata()` only runs during OSD compositing | Main playback path skips HDR metadata |
|
||||||
|
| Software decoded frames get no colorspace attachments | mpv knows colorspace but doesn't pass it to pixel buffer |
|
||||||
|
| VideoToolbox metadata not verified | May or may not have HDR attachments |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## mp_image Colorspace Structures
|
||||||
|
|
||||||
|
mpv uses libplacebo's colorspace structures. Here's how colorspace info flows:
|
||||||
|
|
||||||
|
### Structure Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
mp_image (video/mp_image.h)
|
||||||
|
└─► params: mp_image_params
|
||||||
|
└─► color: pl_color_space
|
||||||
|
│ ├─► primaries: pl_color_primaries (BT.2020, etc.)
|
||||||
|
│ ├─► transfer: pl_color_transfer (PQ, HLG, etc.)
|
||||||
|
│ └─► hdr: pl_hdr_metadata (MaxCLL, MaxFALL, etc.)
|
||||||
|
└─► repr: pl_color_repr
|
||||||
|
├─► sys: pl_color_system (BT.2100_PQ, etc.)
|
||||||
|
└─► levels: pl_color_levels (TV/Full range)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Enums
|
||||||
|
|
||||||
|
#### Color Primaries (`enum pl_color_primaries`)
|
||||||
|
```c
|
||||||
|
PL_COLOR_PRIM_UNKNOWN = 0,
|
||||||
|
PL_COLOR_PRIM_BT_709, // HD/SDR standard
|
||||||
|
PL_COLOR_PRIM_BT_2020, // UHD/HDR wide gamut ← HDR
|
||||||
|
PL_COLOR_PRIM_DCI_P3, // DCI P3 (cinema)
|
||||||
|
PL_COLOR_PRIM_DISPLAY_P3, // Display P3 (Apple)
|
||||||
|
// ... more
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Transfer Functions (`enum pl_color_transfer`)
|
||||||
|
```c
|
||||||
|
PL_COLOR_TRC_UNKNOWN = 0,
|
||||||
|
PL_COLOR_TRC_BT_1886, // SDR gamma
|
||||||
|
PL_COLOR_TRC_SRGB, // sRGB
|
||||||
|
PL_COLOR_TRC_PQ, // SMPTE 2084 PQ (HDR10/DolbyVision) ← HDR
|
||||||
|
PL_COLOR_TRC_HLG, // ITU-R BT.2100 HLG ← HDR
|
||||||
|
// ... more
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Color Systems (`enum pl_color_system`)
|
||||||
|
```c
|
||||||
|
PL_COLOR_SYSTEM_BT_709, // HD/SDR
|
||||||
|
PL_COLOR_SYSTEM_BT_2020_NC, // UHD (non-constant luminance)
|
||||||
|
PL_COLOR_SYSTEM_BT_2100_PQ, // HDR10 ← HDR
|
||||||
|
PL_COLOR_SYSTEM_BT_2100_HLG, // HLG ← HDR
|
||||||
|
PL_COLOR_SYSTEM_DOLBYVISION, // Dolby Vision ← HDR
|
||||||
|
// ... more
|
||||||
|
```
|
||||||
|
|
||||||
|
### HDR Metadata Structure (`struct pl_hdr_metadata`)
|
||||||
|
```c
|
||||||
|
struct pl_hdr_metadata {
|
||||||
|
struct pl_raw_primaries prim; // CIE xy primaries
|
||||||
|
float min_luma, max_luma; // Luminance range (cd/m²)
|
||||||
|
float max_cll; // Maximum Content Light Level
|
||||||
|
float max_fall; // Maximum Frame-Average Light Level
|
||||||
|
// ... more
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing Colorspace in vo_avfoundation
|
||||||
|
|
||||||
|
```c
|
||||||
|
// In draw_frame():
|
||||||
|
struct mp_image *mpi = frame->current;
|
||||||
|
|
||||||
|
// Color primaries
|
||||||
|
enum pl_color_primaries prim = mpi->params.color.primaries;
|
||||||
|
|
||||||
|
// Transfer function
|
||||||
|
enum pl_color_transfer trc = mpi->params.color.transfer;
|
||||||
|
|
||||||
|
// HDR metadata
|
||||||
|
struct pl_hdr_metadata hdr = mpi->params.color.hdr;
|
||||||
|
|
||||||
|
// HDR detection
|
||||||
|
bool is_hdr = (trc == PL_COLOR_TRC_PQ || trc == PL_COLOR_TRC_HLG);
|
||||||
|
bool is_wide_gamut = (prim == PL_COLOR_PRIM_BT_2020);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Fix
|
||||||
|
|
||||||
|
### Required Changes in vo_avfoundation
|
||||||
|
|
||||||
|
**File:** `MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch`
|
||||||
|
|
||||||
|
**Location:** After line 821 in `draw_frame()`, before the sample buffer is created.
|
||||||
|
|
||||||
|
#### Add Required Include
|
||||||
|
```c
|
||||||
|
#include "video/csputils.h" // For pl_color_* enums (if not already included)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Add HDR Metadata Attachment Function
|
||||||
|
```c
|
||||||
|
// Add after copy_hdr_metadata() function (around line 270)
|
||||||
|
static void attach_hdr_metadata(struct vo *vo, CVPixelBufferRef pixbuf,
|
||||||
|
struct mp_image *mpi)
|
||||||
|
{
|
||||||
|
enum pl_color_primaries prim = mpi->params.color.primaries;
|
||||||
|
enum pl_color_transfer trc = mpi->params.color.transfer;
|
||||||
|
|
||||||
|
// Attach BT.2020 color primaries (HDR wide color gamut)
|
||||||
|
if (prim == PL_COLOR_PRIM_BT_2020) {
|
||||||
|
CVBufferSetAttachment(pixbuf, kCVImageBufferColorPrimariesKey,
|
||||||
|
kCVImageBufferColorPrimaries_ITU_R_2020,
|
||||||
|
kCVAttachmentMode_ShouldPropagate);
|
||||||
|
CVBufferSetAttachment(pixbuf, kCVImageBufferYCbCrMatrixKey,
|
||||||
|
kCVImageBufferYCbCrMatrix_ITU_R_2020,
|
||||||
|
kCVAttachmentMode_ShouldPropagate);
|
||||||
|
|
||||||
|
MP_VERBOSE(vo, "HDR: Attached BT.2020 color primaries\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach PQ transfer function (HDR10/Dolby Vision)
|
||||||
|
if (trc == PL_COLOR_TRC_PQ) {
|
||||||
|
CVBufferSetAttachment(pixbuf, kCVImageBufferTransferFunctionKey,
|
||||||
|
kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ,
|
||||||
|
kCVAttachmentMode_ShouldPropagate);
|
||||||
|
|
||||||
|
MP_VERBOSE(vo, "HDR: Attached PQ transfer function (HDR10)\n");
|
||||||
|
}
|
||||||
|
// Attach HLG transfer function
|
||||||
|
else if (trc == PL_COLOR_TRC_HLG) {
|
||||||
|
CVBufferSetAttachment(pixbuf, kCVImageBufferTransferFunctionKey,
|
||||||
|
kCVImageBufferTransferFunction_ITU_R_2100_HLG,
|
||||||
|
kCVAttachmentMode_ShouldPropagate);
|
||||||
|
|
||||||
|
MP_VERBOSE(vo, "HDR: Attached HLG transfer function\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach HDR static metadata if available
|
||||||
|
struct pl_hdr_metadata hdr = mpi->params.color.hdr;
|
||||||
|
if (hdr.max_cll > 0 || hdr.max_fall > 0) {
|
||||||
|
// ContentLightLevelInfo is a 4-byte structure:
|
||||||
|
// - 2 bytes: MaxCLL (max content light level)
|
||||||
|
// - 2 bytes: MaxFALL (max frame-average light level)
|
||||||
|
uint16_t cll_data[2] = {
|
||||||
|
(uint16_t)fminf(hdr.max_cll, 65535.0f),
|
||||||
|
(uint16_t)fminf(hdr.max_fall, 65535.0f)
|
||||||
|
};
|
||||||
|
|
||||||
|
CFDataRef cllInfo = CFDataCreate(NULL, (const UInt8 *)cll_data, sizeof(cll_data));
|
||||||
|
if (cllInfo) {
|
||||||
|
CVBufferSetAttachment(pixbuf, kCVImageBufferContentLightLevelInfoKey,
|
||||||
|
cllInfo, kCVAttachmentMode_ShouldPropagate);
|
||||||
|
CFRelease(cllInfo);
|
||||||
|
|
||||||
|
MP_VERBOSE(vo, "HDR: Attached CLL metadata (MaxCLL=%d, MaxFALL=%d)\n",
|
||||||
|
cll_data[0], cll_data[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Call the Function in draw_frame()
|
||||||
|
|
||||||
|
```c
|
||||||
|
// In draw_frame(), after line 821 (after getting pixbuf), add:
|
||||||
|
|
||||||
|
// Attach HDR colorspace metadata to pixel buffer
|
||||||
|
// This ensures the display layer receives proper HDR signaling
|
||||||
|
attach_hdr_metadata(vo, pixbuf, mpi);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete draw_frame() Modification
|
||||||
|
|
||||||
|
The modified section should look like:
|
||||||
|
|
||||||
|
```c
|
||||||
|
CVPixelBufferRef pixbuf = NULL;
|
||||||
|
bool pixbufNeedsRelease = false;
|
||||||
|
|
||||||
|
// Handle different input formats
|
||||||
|
if (mpi->imgfmt == IMGFMT_VIDEOTOOLBOX) {
|
||||||
|
// Hardware decoded: zero-copy passthrough
|
||||||
|
pixbuf = (CVPixelBufferRef)mpi->planes[3];
|
||||||
|
} else {
|
||||||
|
// Software decoded: upload to CVPixelBuffer
|
||||||
|
pixbuf = upload_software_frame(vo, mpi);
|
||||||
|
if (!pixbuf) {
|
||||||
|
MP_ERR(vo, "Failed to upload software frame\n");
|
||||||
|
mp_image_unrefp(&mpi);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pixbufNeedsRelease = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// >>> NEW: Attach HDR colorspace metadata <<<
|
||||||
|
attach_hdr_metadata(vo, pixbuf, mpi);
|
||||||
|
|
||||||
|
CVPixelBufferRef finalBuffer = pixbuf;
|
||||||
|
bool needsRelease = false;
|
||||||
|
// ... rest of the function
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative Solutions
|
||||||
|
|
||||||
|
### Option A: Enable composite_osd Mode (Quick Test)
|
||||||
|
|
||||||
|
Since `copy_hdr_metadata()` works in composite mode, try enabling it:
|
||||||
|
```
|
||||||
|
--avfoundation-composite-osd=yes
|
||||||
|
```
|
||||||
|
|
||||||
|
This would trigger the existing HDR metadata path. Downside: OSD compositing has performance overhead.
|
||||||
|
|
||||||
|
### Option B: Full vo_avfoundation Fix (Recommended)
|
||||||
|
|
||||||
|
Modify the driver to always attach colorspace metadata based on `mp_image` params. This is the implementation described above.
|
||||||
|
|
||||||
|
### Option C: Dual Player Approach
|
||||||
|
|
||||||
|
Use AVPlayer for HDR content, mpv for everything else. This is what Swiftfin does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
- [ ] Clone MPVKit fork
|
||||||
|
- [ ] Modify `0004-avfoundation-video-output.patch`:
|
||||||
|
- [ ] Add `attach_hdr_metadata()` function
|
||||||
|
- [ ] Call it in `draw_frame()` after getting pixbuf
|
||||||
|
- [ ] Add necessary includes if needed
|
||||||
|
- [ ] Rebuild MPVKit
|
||||||
|
- [ ] Test with HDR10 content on tvOS
|
||||||
|
- [ ] Verify TV shows HDR indicator
|
||||||
|
- [ ] Test with HLG content
|
||||||
|
- [ ] Test with Dolby Vision content (may need additional work)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Implementation Status
|
||||||
|
|
||||||
|
**What's implemented in Streamyfin:**
|
||||||
|
|
||||||
|
1. **HDR Detection** (`MPVLayerRenderer.swift`)
|
||||||
|
- Reads `video-params/primaries` and `video-params/gamma` from mpv
|
||||||
|
- Detects HDR10 (bt.2020 + pq), HLG, Dolby Vision
|
||||||
|
|
||||||
|
2. **AVDisplayCriteria** (`MpvPlayerView.swift`)
|
||||||
|
- Sets `preferredDisplayCriteria` on tvOS 17.0+ when HDR detected
|
||||||
|
- Creates CMFormatDescription with HDR color extensions
|
||||||
|
|
||||||
|
3. **target-colorspace-hint** (`MPVLayerRenderer.swift`)
|
||||||
|
- Added `target-colorspace-hint=yes` for tvOS
|
||||||
|
|
||||||
|
**What's NOT working:**
|
||||||
|
- TV doesn't show HDR indicator
|
||||||
|
- Colors appear washed out
|
||||||
|
- The pixel buffers lack HDR metadata attachments ← **This is what the fix addresses**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Industry Context
|
||||||
|
|
||||||
| Project | Player | HDR Status |
|
| Project | Player | HDR Status |
|
||||||
|---------|--------|------------|
|
|---------|--------|------------|
|
||||||
| [Swiftfin](https://github.com/jellyfin/Swiftfin/issues/811) | VLCKit | Washed out, no HDR signal |
|
| [Swiftfin](https://github.com/jellyfin/Swiftfin/issues/811) | VLCKit | Washed out, uses AVPlayer for HDR |
|
||||||
| [Plex](https://freetime.mikeconnelly.com/archives/8360) | mpv (Enhanced Player) | No HDR support |
|
| [Plex](https://freetime.mikeconnelly.com/archives/8360) | mpv | No HDR support |
|
||||||
| Infuse | Custom Metal engine | Works correctly |
|
| Infuse | Custom Metal engine | Works correctly |
|
||||||
|
|
||||||
**Key quote from mpv maintainer** ([issue #9633](https://github.com/mpv-player/mpv/issues/9633)):
|
**Key insight:** No mpv-based player has solved HDR on tvOS. This fix could be a first.
|
||||||
> "mpv doesn't support metal at all (likely won't ever)"
|
|
||||||
|
|
||||||
### 2. Why Infuse Works
|
|
||||||
|
|
||||||
Infuse uses a **custom Metal-based video rendering engine** built from scratch - not mpv, not VLCKit, not AVPlayer. This allows them to properly handle HDR passthrough with the correct pixel formats and color spaces.
|
|
||||||
|
|
||||||
Source: [Firecore Community](https://community.firecore.com/t/what-player-does-infuse-use/38003)
|
|
||||||
|
|
||||||
### 3. Swiftfin's Solution
|
|
||||||
|
|
||||||
Swiftfin offers two players:
|
|
||||||
- **VLCKit player** (default) - No HDR support
|
|
||||||
- **Native player (AVPlayer)** - HDR works correctly
|
|
||||||
|
|
||||||
When using the native player with proper stream configuration (HEVC `hvc1`, fMP4-HLS), Apple TV correctly switches to HDR mode.
|
|
||||||
|
|
||||||
Source: [Swiftfin Issue #331](https://github.com/jellyfin/Swiftfin/issues/331)
|
|
||||||
|
|
||||||
### 4. Apple's HDR Requirements
|
|
||||||
|
|
||||||
According to [WWDC22: Display HDR video in EDR with AVFoundation and Metal](https://developer.apple.com/videos/play/wwdc2022/110565/):
|
|
||||||
|
|
||||||
**Required for EDR (Extended Dynamic Range):**
|
|
||||||
```swift
|
|
||||||
// On CAMetalLayer
|
|
||||||
layer.wantsExtendedDynamicRangeContent = true
|
|
||||||
layer.pixelFormat = MTLPixelFormatRGBA16Float
|
|
||||||
layer.colorspace = kCGColorSpaceExtendedLinearDisplayP3
|
|
||||||
```
|
|
||||||
|
|
||||||
**For AVPlayerItemVideoOutput:**
|
|
||||||
```swift
|
|
||||||
let videoColorProperties = [
|
|
||||||
AVVideoColorPrimariesKey: AVVideoColorPrimaries_P3_D65,
|
|
||||||
AVVideoTransferFunctionKey: AVVideoTransferFunction_Linear,
|
|
||||||
AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_2020
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. CVPixelBuffer HDR Metadata
|
|
||||||
|
|
||||||
For pixel buffers to be recognized as HDR, they need colorspace attachments:
|
|
||||||
|
|
||||||
```c
|
|
||||||
CVBufferSetAttachment(pixelBuffer, kCVImageBufferColorPrimariesKey,
|
|
||||||
kCVImageBufferColorPrimaries_ITU_R_2020,
|
|
||||||
kCVAttachmentMode_ShouldPropagate);
|
|
||||||
CVBufferSetAttachment(pixelBuffer, kCVImageBufferTransferFunctionKey,
|
|
||||||
kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ, // HDR10
|
|
||||||
kCVAttachmentMode_ShouldPropagate);
|
|
||||||
CVBufferSetAttachment(pixelBuffer, kCVImageBufferYCbCrMatrixKey,
|
|
||||||
kCVImageBufferYCbCrMatrix_ITU_R_2020,
|
|
||||||
kCVAttachmentMode_ShouldPropagate);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. macOS EDR Research
|
|
||||||
|
|
||||||
From [mpv issue #7341](https://github.com/mpv-player/mpv/issues/7341), testing showed:
|
|
||||||
- **mpv playback**: `maximumExtendedDynamicRangeColorComponentValue: 1.0`
|
|
||||||
- **QuickTime playback**: `maximumExtendedDynamicRangeColorComponentValue: 2.0`
|
|
||||||
|
|
||||||
QuickTime uses `CAOpenGLLayer.wantsExtendedDynamicRangeContent` to enable EDR.
|
|
||||||
|
|
||||||
### 7. iOS/tvOS OpenGL Limitations
|
|
||||||
|
|
||||||
From [mpv issue #8467](https://github.com/mpv-player/mpv/issues/8467):
|
|
||||||
> "Disabling HDR peak computation (one or more of the following is not supported: compute shaders=0, SSBO=0)"
|
|
||||||
|
|
||||||
Apple's OpenGL implementation lacks compute shader and SSBO support, making traditional HDR peak computation impossible.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Potential Solutions
|
|
||||||
|
|
||||||
### ~~Option A: Enable EDR on tvOS (Quick Test)~~ - RULED OUT
|
|
||||||
|
|
||||||
**Status:** Tested and failed - `wantsExtendedDynamicRangeContent` is **not available on tvOS**.
|
|
||||||
|
|
||||||
The API is iOS-only. Attempting to use it on tvOS results in:
|
|
||||||
> 'wantsExtendedDynamicRangeContent' is unavailable in tvOS
|
|
||||||
|
|
||||||
This confirms tvOS requires a different approach for HDR.
|
|
||||||
|
|
||||||
### Option B: Modify vo_avfoundation in MPVKit
|
|
||||||
|
|
||||||
Add HDR colorspace metadata when creating CVPixelBuffers.
|
|
||||||
|
|
||||||
**Location:** `streamyfin/MPVKit` fork, vo_avfoundation driver
|
|
||||||
|
|
||||||
**Required changes:**
|
|
||||||
1. Detect HDR content (check video color primaries/transfer function)
|
|
||||||
2. Attach colorspace metadata to pixel buffers
|
|
||||||
3. Possibly configure AVSampleBufferDisplayLayer for HDR
|
|
||||||
|
|
||||||
**Pros:** Fixes the root cause
|
|
||||||
**Cons:** Requires modifying and rebuilding MPVKit
|
|
||||||
|
|
||||||
### Option C: Dual Player Approach (Like Swiftfin)
|
|
||||||
|
|
||||||
Implement AVPlayer-based playback for HDR content, keep mpv for everything else.
|
|
||||||
|
|
||||||
**Pros:** Proven solution, full HDR/DV support
|
|
||||||
**Cons:** Significant development effort, two player implementations to maintain
|
|
||||||
|
|
||||||
### Option D: Accept Limitation
|
|
||||||
|
|
||||||
Document that HDR passthrough is not supported with the mpv player on tvOS.
|
|
||||||
|
|
||||||
**Pros:** No development work
|
|
||||||
**Cons:** Poor user experience for HDR content
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommended Approach
|
|
||||||
|
|
||||||
1. **First:** Try Option A (enable EDR on tvOS) - simple test
|
|
||||||
2. **If that fails:** Investigate Option B (modify vo_avfoundation in MPVKit)
|
|
||||||
3. **Long-term:** Consider Option C (dual player) for full HDR support
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -161,60 +414,23 @@ Document that HDR passthrough is not supported with the mpv player on tvOS.
|
|||||||
- [AVDisplayManager](https://developer.apple.com/documentation/avkit/avdisplaymanager)
|
- [AVDisplayManager](https://developer.apple.com/documentation/avkit/avdisplaymanager)
|
||||||
- [AVDisplayCriteria](https://developer.apple.com/documentation/avkit/avdisplaycriteria)
|
- [AVDisplayCriteria](https://developer.apple.com/documentation/avkit/avdisplaycriteria)
|
||||||
- [WWDC22: Display HDR video in EDR](https://developer.apple.com/videos/play/wwdc2022/110565/)
|
- [WWDC22: Display HDR video in EDR](https://developer.apple.com/videos/play/wwdc2022/110565/)
|
||||||
- [WWDC20: Edit and play back HDR video](https://developer.apple.com/videos/play/wwdc2020/10009/)
|
|
||||||
|
|
||||||
### Related Projects
|
### CVImageBuffer Keys
|
||||||
- [Swiftfin HDR Issues](https://github.com/jellyfin/Swiftfin/issues/811)
|
- `kCVImageBufferColorPrimariesKey` - Color gamut (BT.709, BT.2020, P3)
|
||||||
- [Swiftfin Match Content](https://github.com/jellyfin/Swiftfin/issues/331)
|
- `kCVImageBufferTransferFunctionKey` - Transfer function (sRGB, PQ, HLG)
|
||||||
- [mpv HDR on iOS](https://github.com/mpv-player/mpv/issues/9633)
|
- `kCVImageBufferYCbCrMatrixKey` - YCbCr conversion matrix
|
||||||
- [mpv macOS EDR](https://github.com/mpv-player/mpv/issues/7341)
|
- `kCVImageBufferMasteringDisplayColorVolumeKey` - Mastering display metadata
|
||||||
- [mpv HDR passthrough](https://github.com/mpv-player/mpv/issues/11812)
|
- `kCVImageBufferContentLightLevelInfoKey` - MaxCLL/MaxFALL
|
||||||
|
|
||||||
### Articles
|
### mpv/libplacebo Source
|
||||||
- [Plex's mpv Player](https://freetime.mikeconnelly.com/archives/8360)
|
- mp_image struct: `video/mp_image.h`
|
||||||
- [Rendering HDR Video with AVFoundation and Metal](https://metalbyexample.com/hdr-video/)
|
- Colorspace enums: libplacebo `pl_color.h`
|
||||||
|
- vo_avfoundation: `MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch`
|
||||||
|
|
||||||
---
|
### Key Functions in vo_avfoundation
|
||||||
|
| Function | Line | Purpose |
|
||||||
## Current Implementation Status
|
|----------|------|---------|
|
||||||
|
| `draw_frame()` | 781 | Main frame rendering |
|
||||||
**What we've implemented so far:**
|
| `copy_hdr_metadata()` | 253 | Copy HDR metadata between buffers |
|
||||||
|
| `upload_software_frame()` | 295 | Upload SW frames to CVPixelBuffer |
|
||||||
1. **HDR Detection** (`MPVLayerRenderer.swift`)
|
| `composite_frame()` | 582 | OSD compositing with HDR support |
|
||||||
- Reads `video-params/primaries` and `video-params/gamma` from mpv
|
|
||||||
- Detects HDR10 (bt.2020 + pq), HLG, Dolby Vision
|
|
||||||
- Logs: `HDR Detection - primaries: bt.2020, gamma: pq, fps: 23.976`
|
|
||||||
|
|
||||||
2. **AVDisplayCriteria** (`MpvPlayerView.swift`)
|
|
||||||
- Sets `preferredDisplayCriteria` on tvOS 17.0+ when HDR detected
|
|
||||||
- Creates CMFormatDescription with proper HDR color extensions
|
|
||||||
- Logs: `🎬 HDR: Setting display criteria to hdr10, fps: 23.976`
|
|
||||||
|
|
||||||
3. **target-colorspace-hint** (`MPVLayerRenderer.swift`)
|
|
||||||
- Added `target-colorspace-hint=yes` for tvOS to signal colorspace to display
|
|
||||||
|
|
||||||
**What's NOT working:**
|
|
||||||
- TV doesn't show HDR indicator
|
|
||||||
- Colors still appear washed out
|
|
||||||
- The display mode switch may not be happening
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps for Investigation
|
|
||||||
|
|
||||||
1. **Verify AVDisplayCriteria is being honored:**
|
|
||||||
- Check if Apple TV settings allow app-requested display mode changes
|
|
||||||
- Verify the CMFormatDescription is correctly formed
|
|
||||||
|
|
||||||
2. **Examine vo_avfoundation pixel buffer creation:**
|
|
||||||
- Clone MPVKit source
|
|
||||||
- Find where CVPixelBuffers are created
|
|
||||||
- Check if colorspace attachments are being set
|
|
||||||
|
|
||||||
3. **Test with AVSampleBufferDisplayLayer debugging:**
|
|
||||||
- Log pixel buffer attachments
|
|
||||||
- Verify layer configuration
|
|
||||||
|
|
||||||
4. **Consider testing with VLCKit:**
|
|
||||||
- Swiftfin's VLCKit has same issue
|
|
||||||
- Their solution: use AVPlayer for HDR content
|
|
||||||
|
|||||||
@@ -334,7 +334,7 @@
|
|||||||
"server_url_placeholder": "Seerr URL",
|
"server_url_placeholder": "Seerr URL",
|
||||||
"password": "Lösenord",
|
"password": "Lösenord",
|
||||||
"password_placeholder": "Ange lösenord för Jellyfin användare {{username}}",
|
"password_placeholder": "Ange lösenord för Jellyfin användare {{username}}",
|
||||||
"login_button": "Login",
|
"login_button": "Logga in",
|
||||||
"total_media_requests": "Totalt antal mediaförfrågningar",
|
"total_media_requests": "Totalt antal mediaförfrågningar",
|
||||||
"movie_quota_limit": "Gräns för filmkvot",
|
"movie_quota_limit": "Gräns för filmkvot",
|
||||||
"movie_quota_days": "Filmkvot Dagar",
|
"movie_quota_days": "Filmkvot Dagar",
|
||||||
|
|||||||
Reference in New Issue
Block a user