From a173db918014fec3e3aee354dc30d448decdd134 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 19 Jan 2026 08:21:55 +0100 Subject: [PATCH] wip --- components/ContinueWatchingPoster.tv.tsx | 2 +- components/WatchedIndicator.tsx | 31 +- components/inputs/TVPinInput.tsx | 141 +++++ components/login/TVInput.tsx | 6 +- components/login/TVLogin.tsx | 98 +--- components/login/TVPINEntryModal.tsx | 327 ++++++++++++ components/login/TVPasswordEntryModal.tsx | 337 ++++++++++++ components/login/TVPreviousServersList.tsx | 4 + components/login/TVSaveAccountModal.tsx | 435 ++++++++++++++++ components/login/TVSaveAccountToggle.tsx | 6 +- components/login/TVServerCard.tsx | 9 +- components/series/TVEpisodeCard.tsx | 2 +- docs/research/hdr-mpv.md | 580 ++++++++++++++------- translations/sv.json | 2 +- 14 files changed, 1718 insertions(+), 262 deletions(-) create mode 100644 components/inputs/TVPinInput.tsx create mode 100644 components/login/TVPINEntryModal.tsx create mode 100644 components/login/TVPasswordEntryModal.tsx create mode 100644 components/login/TVSaveAccountModal.tsx diff --git a/components/ContinueWatchingPoster.tv.tsx b/components/ContinueWatchingPoster.tv.tsx index 56111119..7543e49a 100644 --- a/components/ContinueWatchingPoster.tv.tsx +++ b/components/ContinueWatchingPoster.tv.tsx @@ -119,7 +119,7 @@ const ContinueWatchingPoster: React.FC = ({ )} - {!item.UserData?.Played && } + ); diff --git a/components/WatchedIndicator.tsx b/components/WatchedIndicator.tsx index c815eaf9..0d2999f5 100644 --- a/components/WatchedIndicator.tsx +++ b/components/WatchedIndicator.tsx @@ -1,8 +1,37 @@ +import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type React from "react"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; 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 ( + + + + ); + } + return null; + } + + // Mobile: Show purple triangle for unwatched return ( <> {item.UserData?.Played === false && diff --git a/components/inputs/TVPinInput.tsx b/components/inputs/TVPinInput.tsx new file mode 100644 index 00000000..d55970eb --- /dev/null +++ b/components/inputs/TVPinInput.tsx @@ -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 { + value: string; + onChangeText: (text: string) => void; + length?: number; + label?: string; + hasTVPreferredFocus?: boolean; +} + +export interface TVPinInputRef { + focus: () => void; +} + +const TVPinInputComponent = React.forwardRef( + (props, ref) => { + const { + value, + onChangeText, + length = 4, + label, + hasTVPreferredFocus, + placeholder, + ...rest + } = props; + + const inputRef = useRef(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 ( + inputRef.current?.focus()} + onFocus={() => { + setIsFocused(true); + animateFocus(true); + inputRef.current?.focus(); + }} + onBlur={() => { + setIsFocused(false); + animateFocus(false); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + {label && {label}} + { + setIsFocused(true); + animateFocus(true); + }} + onBlur={() => { + setIsFocused(false); + animateFocus(false); + }} + {...rest} + /> + + + ); + }, +); + +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, + }, +}); diff --git a/components/login/TVInput.tsx b/components/login/TVInput.tsx index 6704e6e3..40c2b8d3 100644 --- a/components/login/TVInput.tsx +++ b/components/login/TVInput.tsx @@ -10,12 +10,14 @@ import { interface TVInputProps extends TextInputProps { label?: string; hasTVPreferredFocus?: boolean; + disabled?: boolean; } export const TVInput: React.FC = ({ label, placeholder, hasTVPreferredFocus, + disabled = false, style, ...props }) => { @@ -49,7 +51,9 @@ export const TVInput: React.FC = ({ onPress={() => inputRef.current?.focus()} onFocus={handleFocus} onBlur={handleBlur} - hasTVPreferredFocus={hasTVPreferredFocus} + hasTVPreferredFocus={hasTVPreferredFocus && !disabled} + disabled={disabled} + focusable={!disabled} > void; label: string }> = ({ - onPress, - label, -}) => { +const TVBackButton: React.FC<{ + onPress: () => void; + label: string; + disabled?: boolean; +}> = ({ onPress, label, disabled = false }) => { const [isFocused, setIsFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -70,6 +69,8 @@ const TVBackButton: React.FC<{ onPress: () => void; label: string }> = ({ animateFocus(false); }} style={{ alignSelf: "flex-start", marginBottom: 40 }} + disabled={disabled} + focusable={!disabled} > { useState(null); const [actionSheetKey, setActionSheetKey] = useState(0); - // Server discovery - const { - servers: discoveredServers, - isSearching, - startDiscovery, - } = useJellyfinDiscovery(); + // Track if any modal is open to disable background focus + const isAnyModalOpen = + showSaveModal || + pinModalVisible || + passwordModalVisible || + showServerActionSheet; // Auto login from URL params useEffect(() => { @@ -470,6 +471,7 @@ export const TVLogin: React.FC = () => { removeServer()} label={t("login.change_server")} + disabled={isAnyModalOpen} /> {/* Title */} @@ -513,6 +515,7 @@ export const TVLogin: React.FC = () => { textContentType='username' returnKeyType='next' hasTVPreferredFocus + disabled={isAnyModalOpen} /> @@ -528,6 +531,7 @@ export const TVLogin: React.FC = () => { autoCapitalize='none' textContentType='password' returnKeyType='done' + disabled={isAnyModalOpen} /> @@ -537,6 +541,7 @@ export const TVLogin: React.FC = () => { value={saveAccount} onValueChange={setSaveAccount} label={t("save_account.save_for_later")} + disabled={isAnyModalOpen} /> @@ -623,11 +628,12 @@ export const TVLogin: React.FC = () => { textContentType='URL' returnKeyType='done' hasTVPreferredFocus + disabled={isAnyModalOpen} /> {/* Connect Button */} - + - {/* Server Discovery */} - - - - - {/* Discovered Servers */} - {discoveredServers.length > 0 && ( - - - {t("server.servers")} - - - {discoveredServers.map((server) => ( - { - setServerURL(server.address); - if (server.serverName) { - setServerName(server.serverName); - } - handleConnect(server.address); - }} - /> - ))} - - - )} - {/* Previous Servers */} { onPasswordRequired={handlePasswordRequired} onServerAction={handleServerAction} loginServerOverride={loginTriggerServer} + disabled={isAnyModalOpen} /> @@ -709,7 +663,7 @@ export const TVLogin: React.FC = () => { {/* Save Account Modal */} - { setShowSaveModal(false); @@ -720,7 +674,7 @@ export const TVLogin: React.FC = () => { /> {/* PIN Entry Modal */} - { setPinModalVisible(false); @@ -735,7 +689,7 @@ export const TVLogin: React.FC = () => { /> {/* Password Entry Modal */} - { setPasswordModalVisible(false); diff --git a/components/login/TVPINEntryModal.tsx b/components/login/TVPINEntryModal.tsx new file mode 100644 index 00000000..25d9ce74 --- /dev/null +++ b/components/login/TVPINEntryModal.tsx @@ -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 ( + + + + {label} + + + + ); +}; + +export const TVPINEntryModal: React.FC = ({ + visible, + onClose, + onSuccess, + onForgotPIN, + serverUrl, + userId, + username, +}) => { + const { t } = useTranslation(); + const [isReady, setIsReady] = useState(false); + const [pinCode, setPinCode] = useState(""); + const [error, setError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + const pinInputRef = useRef(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 ( + + + + + {/* Header */} + + {t("pin.enter_pin")} + + {t("pin.enter_pin_for", { username })} + + + + {/* PIN Input */} + {isReady && ( + + + {error && {error}} + {isVerifying && ( + + {t("common.verifying")} + + )} + + )} + + {/* Forgot PIN */} + {isReady && onForgotPIN && ( + + + + )} + + + + + ); +}; + +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", + }, +}); diff --git a/components/login/TVPasswordEntryModal.tsx b/components/login/TVPasswordEntryModal.tsx new file mode 100644 index 00000000..1473cf86 --- /dev/null +++ b/components/login/TVPasswordEntryModal.tsx @@ -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; + 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 ( + + + {loading ? ( + + ) : ( + <> + + + {label} + + + )} + + + ); +}; + +// 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(null); + + return ( + inputRef.current?.focus()} + onFocus={() => { + handleFocus(); + inputRef.current?.focus(); + }} + onBlur={handleBlur} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + + + ); +}; + +export const TVPasswordEntryModal: React.FC = ({ + visible, + onClose, + onSubmit, + username, +}) => { + const { t } = useTranslation(); + const [isReady, setIsReady] = useState(false); + const [password, setPassword] = useState(""); + const [error, setError] = useState(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 ( + + + + + {/* Header */} + + {t("password.enter_password")} + + {t("password.enter_password_for", { username })} + + + + {/* Password Input */} + {isReady && ( + + {t("login.password")} + { + setPassword(text); + setError(null); + }} + placeholder={t("login.password")} + onSubmitEditing={handleSubmit} + hasTVPreferredFocus + /> + {error && {error}} + + )} + + {/* Submit Button */} + {isReady && ( + + + + )} + + + + + ); +}; + +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", + }, +}); diff --git a/components/login/TVPreviousServersList.tsx b/components/login/TVPreviousServersList.tsx index 8105db0e..1903709e 100644 --- a/components/login/TVPreviousServersList.tsx +++ b/components/login/TVPreviousServersList.tsx @@ -223,6 +223,8 @@ interface TVPreviousServersListProps { onServerAction?: (server: SavedServer) => void; // Called by parent when "Login" is selected from action sheet loginServerOverride?: SavedServer | null; + // Disable all focusable elements (when a modal is open) + disabled?: boolean; } // Export the action sheet for use in parent components @@ -236,6 +238,7 @@ export const TVPreviousServersList: React.FC = ({ onPasswordRequired, onServerAction, loginServerOverride, + disabled = false, }) => { const { t } = useTranslation(); const [_previousServers, setPreviousServers] = @@ -416,6 +419,7 @@ export const TVPreviousServersList: React.FC = ({ securityIcon={getSecurityIcon(server)} isLoading={loadingServer === server.address} onPress={() => handleServerPress(server)} + disabled={disabled} /> ))} diff --git a/components/login/TVSaveAccountModal.tsx b/components/login/TVSaveAccountModal.tsx new file mode 100644 index 00000000..a1c7d55c --- /dev/null +++ b/components/login/TVSaveAccountModal.tsx @@ -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 ( + + + + + {label} + + + + ); +}; + +// 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 ( + + + + + {label} + + + + ); +}; + +export const TVSaveAccountModal: React.FC = ({ + visible, + onClose, + onSave, + username, +}) => { + const { t } = useTranslation(); + const [isReady, setIsReady] = useState(false); + const [step, setStep] = useState<"select" | "pin">("select"); + const [selectedType, setSelectedType] = useState("none"); + const [pinCode, setPinCode] = useState(""); + const [pinError, setPinError] = useState(null); + const pinInputRef = useRef(null); + + // Use useState for focus tracking (per TV focus guide) + const [firstCardRef, setFirstCardRef] = useState(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 ( + + + + + {/* Header */} + + {t("save_account.title")} + {username} + + + {step === "select" ? ( + // Security selection step + <> + + {t("save_account.security_option")} + + {isReady && ( + + {SECURITY_OPTIONS.map((option, index) => ( + handleOptionSelect(option.type)} + width={220} + height={100} + /> + ))} + + )} + + ) : ( + // PIN entry step + <> + {t("pin.setup_pin")} + + { + setPinCode(text); + setPinError(null); + }} + length={4} + autoFocus + /> + {pinError && {pinError}} + + + {isReady && ( + + + + + )} + + )} + + + + + ); +}; + +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", + }, +}); diff --git a/components/login/TVSaveAccountToggle.tsx b/components/login/TVSaveAccountToggle.tsx index 8c6a9d1d..85ccc3f1 100644 --- a/components/login/TVSaveAccountToggle.tsx +++ b/components/login/TVSaveAccountToggle.tsx @@ -8,6 +8,7 @@ interface TVSaveAccountToggleProps { onValueChange: (value: boolean) => void; label: string; hasTVPreferredFocus?: boolean; + disabled?: boolean; } export const TVSaveAccountToggle: React.FC = ({ @@ -15,6 +16,7 @@ export const TVSaveAccountToggle: React.FC = ({ onValueChange, label, hasTVPreferredFocus, + disabled = false, }) => { const [isFocused, setIsFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -52,7 +54,9 @@ export const TVSaveAccountToggle: React.FC = ({ onPress={() => onValueChange(!value)} onFocus={handleFocus} onBlur={handleBlur} - hasTVPreferredFocus={hasTVPreferredFocus} + hasTVPreferredFocus={hasTVPreferredFocus && !disabled} + disabled={disabled} + focusable={!disabled} > void; hasTVPreferredFocus?: boolean; + disabled?: boolean; } export const TVServerCard: React.FC = ({ @@ -26,6 +27,7 @@ export const TVServerCard: React.FC = ({ isLoading, onPress, hasTVPreferredFocus, + disabled = false, }) => { const [isFocused, setIsFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -58,13 +60,16 @@ export const TVServerCard: React.FC = ({ animateFocus(false); }; + const isDisabled = disabled || isLoading; + return ( = ({ }} /> )} - {!episode.UserData?.Played && } + diff --git a/docs/research/hdr-mpv.md b/docs/research/hdr-mpv.md index f2e114a6..6061e026 100644 --- a/docs/research/hdr-mpv.md +++ b/docs/research/hdr-mpv.md @@ -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. -## 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 #if !os(tvOS) if #available(iOS 17.0, *) { @@ -17,141 +19,392 @@ if #available(iOS 17.0, *) { #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 | |---------|--------|------------| -| [Swiftfin](https://github.com/jellyfin/Swiftfin/issues/811) | VLCKit | Washed out, no HDR signal | -| [Plex](https://freetime.mikeconnelly.com/archives/8360) | mpv (Enhanced Player) | No HDR support | +| [Swiftfin](https://github.com/jellyfin/Swiftfin/issues/811) | VLCKit | Washed out, uses AVPlayer for HDR | +| [Plex](https://freetime.mikeconnelly.com/archives/8360) | mpv | No HDR support | | Infuse | Custom Metal engine | Works correctly | -**Key quote from mpv maintainer** ([issue #9633](https://github.com/mpv-player/mpv/issues/9633)): -> "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 +**Key insight:** No mpv-based player has solved HDR on tvOS. This fix could be a first. --- @@ -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) - [AVDisplayCriteria](https://developer.apple.com/documentation/avkit/avdisplaycriteria) - [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 -- [Swiftfin HDR Issues](https://github.com/jellyfin/Swiftfin/issues/811) -- [Swiftfin Match Content](https://github.com/jellyfin/Swiftfin/issues/331) -- [mpv HDR on iOS](https://github.com/mpv-player/mpv/issues/9633) -- [mpv macOS EDR](https://github.com/mpv-player/mpv/issues/7341) -- [mpv HDR passthrough](https://github.com/mpv-player/mpv/issues/11812) +### CVImageBuffer Keys +- `kCVImageBufferColorPrimariesKey` - Color gamut (BT.709, BT.2020, P3) +- `kCVImageBufferTransferFunctionKey` - Transfer function (sRGB, PQ, HLG) +- `kCVImageBufferYCbCrMatrixKey` - YCbCr conversion matrix +- `kCVImageBufferMasteringDisplayColorVolumeKey` - Mastering display metadata +- `kCVImageBufferContentLightLevelInfoKey` - MaxCLL/MaxFALL -### Articles -- [Plex's mpv Player](https://freetime.mikeconnelly.com/archives/8360) -- [Rendering HDR Video with AVFoundation and Metal](https://metalbyexample.com/hdr-video/) +### mpv/libplacebo Source +- mp_image struct: `video/mp_image.h` +- Colorspace enums: libplacebo `pl_color.h` +- vo_avfoundation: `MPVKit/Sources/BuildScripts/patch/libmpv/0004-avfoundation-video-output.patch` ---- - -## Current Implementation Status - -**What we've implemented so far:** - -1. **HDR Detection** (`MPVLayerRenderer.swift`) - - 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 +### Key Functions in vo_avfoundation +| Function | Line | Purpose | +|----------|------|---------| +| `draw_frame()` | 781 | Main frame rendering | +| `copy_hdr_metadata()` | 253 | Copy HDR metadata between buffers | +| `upload_software_frame()` | 295 | Upload SW frames to CVPixelBuffer | +| `composite_frame()` | 582 | OSD compositing with HDR support | diff --git a/translations/sv.json b/translations/sv.json index 779fa842..e6db4004 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -334,7 +334,7 @@ "server_url_placeholder": "Seerr URL", "password": "Lösenord", "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", "movie_quota_limit": "Gräns för filmkvot", "movie_quota_days": "Filmkvot Dagar",