This commit is contained in:
Fredrik Burmester
2026-01-16 15:29:12 +01:00
parent a86df6c46b
commit 3fd76b1356
8 changed files with 989 additions and 153 deletions

View File

@@ -1,3 +1,5 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useRef, useState } from "react";
import {
Animated,
@@ -39,6 +41,49 @@ export function Input(props: InputProps) {
};
if (Platform.isTV) {
const containerStyle = {
height: 48,
borderRadius: 50,
borderWidth: isFocused ? 1.5 : 1,
borderColor: isFocused
? "rgba(255, 255, 255, 0.3)"
: "rgba(255, 255, 255, 0.1)",
overflow: "hidden" as const,
flexDirection: "row" as const,
alignItems: "center" as const,
paddingLeft: 16,
};
const inputElement = (
<>
<Ionicons
name='search'
size={20}
color={isFocused ? "#999" : "#666"}
style={{ marginRight: 12 }}
/>
<TextInput
ref={inputRef}
allowFontScaling={false}
placeholderTextColor='#666'
style={[
{
flex: 1,
height: 48,
fontSize: 18,
fontWeight: "400",
color: "#FFFFFF",
backgroundColor: "transparent",
},
style,
]}
onFocus={handleFocus}
onBlur={handleBlur}
{...otherProps}
/>
</>
);
return (
<Pressable
onPress={() => inputRef.current?.focus()}
@@ -50,66 +95,28 @@ export function Input(props: InputProps) {
transform: [{ scale }],
}}
>
{/* Outer glow when focused */}
{isFocused && (
{Platform.OS === "ios" ? (
<BlurView
intensity={isFocused ? 90 : 80}
tint='dark'
style={containerStyle}
>
{inputElement}
</BlurView>
) : (
<View
style={{
position: "absolute",
top: -4,
left: -4,
right: -4,
bottom: -4,
backgroundColor: "#9334E9",
borderRadius: 18,
opacity: 0.5,
}}
/>
)}
<View
style={{
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
borderWidth: 3,
borderColor: isFocused ? "#FFFFFF" : "#333333",
borderRadius: 14,
overflow: "hidden",
}}
>
{/* Purple accent bar at top when focused */}
{isFocused && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: "#9334E9",
}}
/>
)}
<TextInput
ref={inputRef}
allowFontScaling={false}
placeholderTextColor={isFocused ? "#AAAAAA" : "#666666"}
style={[
containerStyle,
{
height: 60,
fontSize: 22,
fontWeight: "500",
paddingHorizontal: 20,
paddingTop: isFocused ? 4 : 0,
color: "#FFFFFF",
backgroundColor: "transparent",
backgroundColor: isFocused
? "rgba(255, 255, 255, 0.12)"
: "rgba(255, 255, 255, 0.08)",
},
style,
]}
onFocus={handleFocus}
onBlur={handleBlur}
{...otherProps}
/>
</View>
>
{inputElement}
</View>
)}
</Animated.View>
</Pressable>
);

View File

@@ -1,7 +1,7 @@
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, type ViewStyle } from "react-native";
interface TVFocusablePosterProps {
export interface TVFocusablePosterProps {
children: React.ReactNode;
onPress: () => void;
hasTVPreferredFocus?: boolean;
@@ -10,6 +10,7 @@ interface TVFocusablePosterProps {
style?: ViewStyle;
onFocus?: () => void;
onBlur?: () => void;
disabled?: boolean;
}
export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
@@ -21,6 +22,7 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
style,
onFocus: onFocusProp,
onBlur: onBlurProp,
disabled = false,
}) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
@@ -48,7 +50,9 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
animateTo(1);
onBlurProp?.();
}}
hasTVPreferredFocus={hasTVPreferredFocus}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[

View File

@@ -225,20 +225,20 @@ const TVSettingsPanel: FC<{
<View style={selectorStyles.overlay}>
<BlurView intensity={80} tint='dark' style={selectorStyles.blurContainer}>
<View style={selectorStyles.content}>
{/* Tab buttons - no preferred focus, navigate here via up from options */}
{/* Tab buttons - switch automatically on focus */}
<View style={selectorStyles.tabRow}>
{audioOptions.length > 0 && (
<TVSettingsTab
label={t("item_card.audio")}
active={activeTab === "audio"}
onPress={() => setActiveTab("audio")}
onSelect={() => setActiveTab("audio")}
/>
)}
{subtitleOptions.length > 0 && (
<TVSettingsTab
label={t("item_card.subtitles")}
active={activeTab === "subtitle"}
onPress={() => setActiveTab("subtitle")}
onSelect={() => setActiveTab("subtitle")}
/>
)}
</View>
@@ -269,13 +269,13 @@ const TVSettingsPanel: FC<{
);
};
// Tab button for settings panel
// Tab button for settings panel - switches on focus, no click needed
const TVSettingsTab: FC<{
label: string;
active: boolean;
onPress: () => void;
onSelect: () => void;
hasTVPreferredFocus?: boolean;
}> = ({ label, active, onPress, hasTVPreferredFocus }) => {
}> = ({ label, active, onSelect, hasTVPreferredFocus }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new RNAnimated.Value(1)).current;
@@ -289,10 +289,11 @@ const TVSettingsTab: FC<{
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
// Switch tab automatically on focus
onSelect();
}}
onBlur={() => {
setFocused(false);
@@ -506,8 +507,8 @@ export const Controls: FC<Props> = ({
const [openModal, setOpenModal] = useState<ModalType>(null);
const isModalOpen = openModal !== null;
// Handle swipe up to open settings panel
const handleSwipeUp = useCallback(() => {
// Handle swipe down to open settings panel
const handleSwipeDown = useCallback(() => {
if (!isModalOpen) {
setOpenModal("settings");
}
@@ -629,6 +630,16 @@ export const Controls: FC<Props> = ({
isSeeking,
});
const getFinishTime = () => {
const now = new Date();
const finishTime = new Date(now.getTime() + remainingTime);
return finishTime.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
const toggleControls = useCallback(() => {
setShowControls(!showControls);
}, [showControls, setShowControls]);
@@ -672,7 +683,7 @@ export const Controls: FC<Props> = ({
handleSeekForward,
handleSeekBackward,
disableSeeking: isModalOpen,
onSwipeUp: handleSwipeUp,
onSwipeDown: handleSwipeDown,
});
// Slider hook
@@ -748,9 +759,16 @@ export const Controls: FC<Props> = ({
{/* Center Play Button - shown when paused */}
{!isPlaying && showControls && (
<View style={styles.centerContainer}>
<View style={styles.playButtonContainer}>
<Ionicons name='play' size={80} color='white' />
</View>
<BlurView intensity={40} tint='dark' style={styles.playButtonBlur}>
<View style={styles.playButtonInner}>
<Ionicons
name='play'
size={44}
color='white'
style={styles.playIcon}
/>
</View>
</BlurView>
</View>
)}
@@ -771,14 +789,14 @@ export const Controls: FC<Props> = ({
]}
>
<View style={styles.settingsHint}>
<Text style={styles.settingsHintText}>
{t("player.swipe_down_settings")}
</Text>
<Ionicons
name='chevron-up'
name='chevron-down'
size={16}
color='rgba(255,255,255,0.5)'
/>
<Text style={styles.settingsHintText}>
{t("player.swipe_up_settings")}
</Text>
</View>
</View>
</Animated.View>
@@ -855,9 +873,14 @@ export const Controls: FC<Props> = ({
<Text style={styles.timeText}>
{formatTimeString(currentTime, "ms")}
</Text>
<Text style={styles.timeText}>
-{formatTimeString(remainingTime, "ms")}
</Text>
<View style={styles.timeRight}>
<Text style={styles.timeText}>
-{formatTimeString(remainingTime, "ms")}
</Text>
<Text style={styles.endsAtText}>
{t("player.ends_at")} {getFinishTime()}
</Text>
</View>
</View>
</View>
</Animated.View>
@@ -910,14 +933,23 @@ const styles = StyleSheet.create({
justifyContent: "center",
alignItems: "center",
},
playButtonContainer: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: "rgba(0,0,0,0.5)",
playButtonBlur: {
width: 80,
height: 80,
borderRadius: 40,
overflow: "hidden",
},
playButtonInner: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingLeft: 8,
backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.2)",
borderRadius: 40,
},
playIcon: {
marginLeft: 4,
},
topContainer: {
position: "absolute",
@@ -928,7 +960,7 @@ const styles = StyleSheet.create({
},
topInner: {
flexDirection: "row",
justifyContent: "flex-end",
justifyContent: "center",
},
bottomContainer: {
position: "absolute",
@@ -970,18 +1002,23 @@ const styles = StyleSheet.create({
color: "rgba(255,255,255,0.7)",
fontSize: 22,
},
timeRight: {
flexDirection: "column",
alignItems: "flex-end",
},
endsAtText: {
color: "rgba(255,255,255,0.5)",
fontSize: 16,
marginTop: 2,
},
settingsRow: {
flexDirection: "row",
gap: 12,
},
settingsHint: {
flexDirection: "row",
flexDirection: "column",
alignItems: "center",
gap: 6,
backgroundColor: "rgba(0,0,0,0.3)",
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 20,
gap: 4,
},
settingsHintText: {
color: "rgba(255,255,255,0.5)",

View File

@@ -33,8 +33,8 @@ interface UseRemoteControlProps {
handleSeekBackward: (seconds: number) => void;
/** When true, disables left/right seeking (e.g., when settings modal is open) */
disableSeeking?: boolean;
/** Callback when swipe up is detected - used to open settings */
onSwipeUp?: () => void;
/** Callback when swipe down is detected - used to open settings */
onSwipeDown?: () => void;
}
/**
@@ -55,7 +55,7 @@ export function useRemoteControl({
handleSeekForward,
handleSeekBackward,
disableSeeking = false,
onSwipeUp,
onSwipeDown,
}: UseRemoteControlProps) {
const remoteScrubProgress = useSharedValue<number | null>(null);
const isRemoteScrubbing = useSharedValue(false);
@@ -74,9 +74,9 @@ export function useRemoteControl({
const disableSeekingRef = useRef(disableSeeking);
disableSeekingRef.current = disableSeeking;
// Use ref for onSwipeUp callback
const onSwipeUpRef = useRef(onSwipeUp);
onSwipeUpRef.current = onSwipeUp;
// Use ref for onSwipeDown callback
const onSwipeDownRef = useRef(onSwipeDown);
onSwipeDownRef.current = onSwipeDown;
// MPV uses ms
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
@@ -130,6 +130,10 @@ export function useRemoteControl({
}
case "playPause":
case "select": {
// Skip play/pause when modal is open (let native focus handle selection)
if (disableSeekingRef.current) {
break;
}
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
progress.value = remoteScrubProgress.value;
@@ -148,17 +152,17 @@ export function useRemoteControl({
break;
}
case "down":
// cancel scrubbing on down
// cancel scrubbing and trigger swipe down callback (for settings)
isRemoteScrubbing.value = false;
remoteScrubProgress.value = null;
setShowRemoteBubble(false);
onSwipeDownRef.current?.();
break;
case "up":
// cancel scrubbing and trigger swipe up callback (for settings)
// cancel scrubbing on up
isRemoteScrubbing.value = false;
remoteScrubProgress.value = null;
setShowRemoteBubble(false);
onSwipeUpRef.current?.();
break;
default:
break;