Merge branch 'feat/tv-interface' of https://github.com/streamyfin/streamyfin into feat/tv-interface

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
Lance Chant
2026-05-25 10:35:14 +02:00
17 changed files with 141 additions and 57 deletions

View File

@@ -154,6 +154,13 @@ export default function page() {
: BITRATES[0].value;
const [item, setItem] = useState<BaseItemDto | null>(null);
const initialSeekDoneRef = useRef(false);
const initialPlaybackTicksRef = useRef<number>(
playbackPositionFromUrl
? Number.parseInt(playbackPositionFromUrl, 10)
: (item?.UserData?.PlaybackPositionTicks ?? 0),
);
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
null,
);
@@ -214,12 +221,25 @@ export default function page() {
);
/** Gets the initial playback position from the URL. */
const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10);
// const getInitialPlaybackTicks = useCallback((): number => {
// if (playbackPositionFromUrl) {
// return Number.parseInt(playbackPositionFromUrl, 10);
// }
// return item?.UserData?.PlaybackPositionTicks ?? 0;
// }, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
useEffect(() => {
if (!tracksReady || !videoRef.current) return;
if (initialSeekDoneRef.current) return;
initialSeekDoneRef.current = true;
const ticks = initialPlaybackTicksRef.current;
if (ticks > 0) {
videoRef.current.seekTo(ticksToSeconds(ticks));
}
return item?.UserData?.PlaybackPositionTicks ?? 0;
}, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
}, [tracksReady]);
useEffect(() => {
const fetchItemData = async () => {
@@ -588,6 +608,11 @@ export default function page() {
: (item?.UserData?.PlaybackPositionTicks ?? 0);
const startPos = ticksToSeconds(startTicks);
console.log(
`[DirectPlayer] Resume position — ticks: ${startTicks}, seconds: ${startPos}, ` +
`fromUrl: ${playbackPositionFromUrl}, itemTicks: ${item?.UserData?.PlaybackPositionTicks ?? 0}`,
);
// Build source config - headers only needed for online streaming
const source: MpvVideoSource = {
url: stream.url,
@@ -743,7 +768,7 @@ export default function page() {
currentPlayStateInfo() as PlaybackProgressInfo,
);
}
if (!Platform.isTV) await activateKeepAwakeAsync();
await activateKeepAwakeAsync();
return;
}
@@ -756,7 +781,7 @@ export default function page() {
currentPlayStateInfo() as PlaybackProgressInfo,
);
}
if (!Platform.isTV) await deactivateKeepAwake();
await deactivateKeepAwake();
return;
}
@@ -1099,13 +1124,6 @@ export default function page() {
applySubtitleSettings();
}, [isVideoLoaded, settings]);
// Seek to resume position after file is loaded (MPV_EVENT_FILE_LOADED)
useEffect(() => {
if (!tracksReady || !videoRef.current) return;
const ticks = getInitialPlaybackTicks();
videoRef.current?.seekTo?.(ticksToSeconds(ticks));
}, [tracksReady, getInitialPlaybackTicks]);
// Apply initial playback speed when video loads
useEffect(() => {
if (!isVideoLoaded || !videoRef.current) return;

View File

@@ -3,6 +3,7 @@ import { FlatList, ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
interface TVHorizontalListProps<T> {
/** Data items to render */
@@ -94,7 +95,7 @@ export function TVHorizontalList<T>({
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginBottom: scaleSize(20),
marginLeft: sizes.padding.scale,
letterSpacing: 0.5,
}}
@@ -125,7 +126,7 @@ export function TVHorizontalList<T>({
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginBottom: scaleSize(20),
marginLeft: sizes.padding.scale,
letterSpacing: 0.5,
}}
@@ -167,7 +168,7 @@ export function TVHorizontalList<T>({
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginBottom: scaleSize(20),
marginLeft: sizes.padding.scale,
letterSpacing: 0.5,
}}

View File

@@ -3,6 +3,7 @@ import React from "react";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
export interface TVItemCardTextProps {
item: BaseItemDto;
@@ -12,7 +13,7 @@ export const TVItemCardText: React.FC<TVItemCardTextProps> = ({ item }) => {
const typography = useScaledTVTypography();
return (
<View style={{ marginTop: 12 }}>
<View style={{ marginTop: scaleSize(12) }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
@@ -23,7 +24,7 @@ export const TVItemCardText: React.FC<TVItemCardTextProps> = ({ item }) => {
style={{
fontSize: typography.callout - 2,
color: "#9CA3AF",
marginTop: 2,
marginTop: scaleSize(2),
}}
>
{item.ProductionYear}

View File

@@ -91,7 +91,7 @@ const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
},
languageCardCode: {
fontSize: typography.callout,
marginTop: 2,
marginTop: scaleSize(2),
},
checkmark: {
position: "absolute",

View File

@@ -45,8 +45,8 @@ export interface TVNextEpisodeCountdownProps {
}
// Position constants
const BOTTOM_WITH_CONTROLS = 300;
const BOTTOM_WITHOUT_CONTROLS = 120;
const BOTTOM_WITH_CONTROLS = scaleSize(300);
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
nextItem,
@@ -75,7 +75,7 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
const imageUrl = getPrimaryImageUrl({
api,
item: nextItem,
width: 360,
width: scaleSize(360),
quality: 80,
});
@@ -244,15 +244,15 @@ const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
marginBottom: scaleSize(12),
},
progressContainer: {
height: 4,
height: scaleSize(4),
backgroundColor: "rgba(255,255,255,0.2)",
borderRadius: 2,
borderRadius: scaleSize(2),
overflow: "hidden",
},
progressBar: {
height: "100%",
backgroundColor: "#fff",
borderRadius: 2,
borderRadius: scaleSize(2),
},
returnFocusGuide: {
height: 1,

View File

@@ -3,6 +3,7 @@ import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVOptionButtonProps {
@@ -42,12 +43,12 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
<View
style={{
backgroundColor: "#fff",
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: scaleSize(8),
paddingVertical: scaleSize(10),
paddingHorizontal: scaleSize(16),
flexDirection: "row",
alignItems: "center",
gap: 8,
gap: scaleSize(8),
maxWidth,
}}
>

View File

@@ -78,7 +78,7 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
fontSize: typography.callout,
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
textAlign: "center",
marginTop: 2,
marginTop: scaleSize(2),
}}
numberOfLines={1}
>

View File

@@ -1,5 +1,6 @@
import React from "react";
import { View } from "react-native";
import { scaleSize } from "@/utils/scaleSize";
export interface TVProgressBarProps {
/** Progress value between 0 and 1 */
@@ -23,14 +24,16 @@ export const TVProgressBar: React.FC<TVProgressBarProps> = React.memo(
height = 4,
}) => {
const clampedProgress = Math.max(0, Math.min(1, progress));
const scaledMaxWidth = scaleSize(maxWidth);
const scaledHeight = scaleSize(height);
return (
<View style={{ maxWidth, marginBottom: 24 }}>
<View style={{ maxWidth: scaledMaxWidth, marginBottom: scaleSize(24) }}>
<View
style={{
height,
height: scaledHeight,
backgroundColor: trackColor,
borderRadius: height / 2,
borderRadius: scaledHeight / 2,
overflow: "hidden",
}}
>
@@ -39,7 +42,7 @@ export const TVProgressBar: React.FC<TVProgressBarProps> = React.memo(
width: `${clampedProgress * 100}%`,
height: "100%",
backgroundColor: fillColor,
borderRadius: height / 2,
borderRadius: scaledHeight / 2,
}}
/>
</View>

View File

@@ -5,6 +5,7 @@ import { ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
export interface TVSeriesNavigationProps {
@@ -33,7 +34,7 @@ export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginBottom: scaleSize(20),
letterSpacing: 0.5,
}}
>

View File

@@ -240,12 +240,12 @@ const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
ratingContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
gap: scaleSize(3),
},
downloadCountContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
gap: scaleSize(3),
},
flagsContainer: {
flexDirection: "row",

View File

@@ -2,6 +2,7 @@ import React from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVTabButtonProps {
@@ -48,10 +49,10 @@ export const TVTabButton: React.FC<TVTabButtonProps> = ({
? "rgba(255,255,255,0.2)"
: "transparent",
borderBottomColor: active ? "#fff" : "transparent",
borderBottomWidth: 2,
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
borderBottomWidth: scaleSize(2),
paddingHorizontal: scaleSize(20),
paddingVertical: scaleSize(12),
borderRadius: scaleSize(8),
},
]}
>

View File

@@ -66,10 +66,10 @@ export const TVThemeMusicIndicator: React.FC<TVThemeMusicIndicatorProps> = ({
<View style={{ marginRight: 0 }}>
<AnimatedEqualizer
color='#FFFFFF'
barWidth={3}
barWidth={scaleSize(3)}
barCount={3}
height={18}
gap={2}
height={scaleSize(18)}
gap={scaleSize(2)}
/>
</View>
)}

View File

@@ -96,7 +96,7 @@ const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
},
trackCardSublabel: {
fontSize: typography.callout,
marginTop: 2,
marginTop: scaleSize(2),
},
checkmark: {
position: "absolute",

View File

@@ -120,10 +120,14 @@ export const TVUserCard = React.forwardRef<View, TVUserCardProps>(
</View>
{/* Text column */}
<View style={{ gap: 4 }}>
<View style={{ gap: scaleSize(4) }}>
{/* Username */}
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
style={{
flexDirection: "row",
alignItems: "center",
gap: scaleSize(8),
}}
>
<Text
style={{
@@ -153,7 +157,7 @@ export const TVUserCard = React.forwardRef<View, TVUserCardProps>(
style={{
flexDirection: "row",
alignItems: "center",
gap: 4,
gap: scaleSize(4),
}}
>
<Ionicons

View File

@@ -14,6 +14,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import {
Pressable,
StyleSheet,
TVFocusGuideView,
useWindowDimensions,
@@ -278,6 +279,9 @@ export const Controls: FC<Props> = ({
null,
);
// Ref for the invisible focus-stealing overlay (prevents hidden buttons from receiving select events)
const focusOverlayRef = useRef<View>(null);
const audioTracks = useMemo(() => {
return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
}, [mediaSource]);
@@ -908,6 +912,33 @@ export const Controls: FC<Props> = ({
setFocusPlayButton(false);
}, [setShowControls]);
// On initial mount when controls start visible, focus the play button.
// playButtonRef transitions from null → View on first render; once set,
// this effect won't re-fire (playButtonRef is a stable reference).
const initialFocusDone = useRef(false);
useEffect(() => {
if (!initialFocusDone.current && playButtonRef && showControls) {
initialFocusDone.current = true;
const t = setTimeout(() => {
playButtonRef.focus();
}, 100);
return () => clearTimeout(t);
}
}, [showControls, playButtonRef]);
// When controls hide (and no skip/countdown overlay is visible), move focus
// to the invisible overlay so hidden buttons can't receive select events.
useEffect(() => {
if (!showControls && !isSkipOrCountdownVisible) {
// Small delay to let the controls fade-out animation start and
// the focus engine settle before stealing focus
const t = setTimeout(() => {
focusOverlayRef.current?.focus();
}, 100);
return () => clearTimeout(t);
}
}, [showControls, isSkipOrCountdownVisible]);
const handleBack = useCallback(() => {
router.back();
}, [router]);
@@ -1025,6 +1056,24 @@ export const Controls: FC<Props> = ({
pointerEvents='none'
/>
{/* Invisible overlay that steals focus when controls are hidden.
Prevents hidden control buttons from receiving select/enter events
from the TV remote. Pressing center button here toggles play/pause. */}
<Pressable
ref={focusOverlayRef}
style={styles.focusStealingOverlay}
pointerEvents={
showControls || isSkipOrCountdownVisible ? "none" : "auto"
}
focusable={!showControls && !isSkipOrCountdownVisible}
hasTVPreferredFocus={!showControls && !isSkipOrCountdownVisible}
onPress={() => {
togglePlay();
setShowControls(true);
setFocusPlayButton(true);
}}
/>
{getTechnicalInfo && (
<TechnicalInfoOverlay
showControls={showControls}
@@ -1177,6 +1226,7 @@ export const Controls: FC<Props> = ({
<Animated.View
style={[styles.bottomContainer, bottomAnimatedStyle]}
pointerEvents={showControls ? "auto" : "none"}
focusable={showControls}
>
<View
style={[
@@ -1379,6 +1429,10 @@ const styles = StyleSheet.create({
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.4)",
},
focusStealingOverlay: {
...StyleSheet.absoluteFillObject,
zIndex: 1,
},
bottomContainer: {
position: "absolute",
bottom: 0,

View File

@@ -79,10 +79,10 @@ export const TVAnimation = {
* Applied to poster sizes and gaps.
*/
const sizeScaleMultipliers: Record<TVTypographyScale, number> = {
[TVTypographyScale.Small]: 0.9,
[TVTypographyScale.Default]: 1.0,
[TVTypographyScale.Large]: 1.1,
[TVTypographyScale.ExtraLarge]: 1.2,
[TVTypographyScale.Small]: 0.63,
[TVTypographyScale.Default]: 0.7,
[TVTypographyScale.Large]: 0.77,
[TVTypographyScale.ExtraLarge]: 0.84,
};
// =============================================================================

View File

@@ -37,10 +37,10 @@ export type TVTypographyKey = keyof typeof TVTypography;
// =============================================================================
const scaleMultipliers: Record<TVTypographyScale, number> = {
[TVTypographyScale.Small]: 0.85,
[TVTypographyScale.Default]: 1.0,
[TVTypographyScale.Large]: 1.2,
[TVTypographyScale.ExtraLarge]: 1.4,
[TVTypographyScale.Small]: 0.6,
[TVTypographyScale.Default]: 0.7,
[TVTypographyScale.Large]: 0.84,
[TVTypographyScale.ExtraLarge]: 0.98,
};
// =============================================================================