This commit is contained in:
Fredrik Burmester
2026-01-19 08:21:55 +01:00
parent a8c07a31d3
commit a173db9180
14 changed files with 1718 additions and 262 deletions

View File

@@ -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>
); );

View File

@@ -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 &&

View 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,
},
});

View File

@@ -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={{

View File

@@ -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);

View 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",
},
});

View 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",
},
});

View File

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

View 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",
},
});

View File

@@ -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={[

View File

@@ -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={[

View File

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

View File

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

View File

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