mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-26 00:36:41 +01:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -91,7 +91,7 @@ const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||
},
|
||||
languageCardCode: {
|
||||
fontSize: typography.callout,
|
||||
marginTop: 2,
|
||||
marginTop: scaleSize(2),
|
||||
},
|
||||
checkmark: {
|
||||
position: "absolute",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -96,7 +96,7 @@ const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||
},
|
||||
trackCardSublabel: {
|
||||
fontSize: typography.callout,
|
||||
marginTop: 2,
|
||||
marginTop: scaleSize(2),
|
||||
},
|
||||
checkmark: {
|
||||
position: "absolute",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user