feat(tv): add technical info overlay to player controls

This commit is contained in:
Fredrik Burmester
2026-01-22 08:10:18 +01:00
parent 4b7007386f
commit 3f882ecade
3 changed files with 95 additions and 36 deletions

View File

@@ -1175,6 +1175,11 @@ export default function page() {
goToNextItem={goToNextItem}
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
addSubtitleFile={addSubtitleFile}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={handleToggleTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
/>
) : (
<Controls

View File

@@ -32,6 +32,7 @@ import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
@@ -40,6 +41,7 @@ import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "./constants";
import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoTime } from "./hooks/useVideoTime";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { TrickplayBubble } from "./TrickplayBubble";
import { useControlsTimeout } from "./useControlsTimeout";
@@ -69,6 +71,11 @@ interface Props {
import("@jellyfin/sdk/lib/generated-client").MediaStream[]
>;
addSubtitleFile?: (path: string) => void;
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
getTechnicalInfo?: () => Promise<TechnicalInfo>;
playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
transcodeReasons?: string[];
}
const TV_SEEKBAR_HEIGHT = 14;
@@ -97,6 +104,11 @@ export const Controls: FC<Props> = ({
goToNextItem: goToNextItemProp,
onRefreshSubtitleTracks,
addSubtitleFile,
showTechnicalInfo,
onToggleTechnicalInfo,
getTechnicalInfo,
playMethod,
transcodeReasons,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
@@ -127,7 +139,7 @@ export const Controls: FC<Props> = ({
const { showSubtitleModal } = useTVSubtitleModal();
// Track which button should have preferred focus when controls show
type LastModalType = "audio" | "subtitle" | null;
type LastModalType = "audio" | "subtitle" | "techInfo" | null;
const [lastOpenedModal, setLastOpenedModal] = useState<LastModalType>(null);
// Track if play button should have focus (when showing controls via up/down D-pad)
@@ -383,6 +395,12 @@ export const Controls: FC<Props> = ({
onRefreshSubtitleTracks,
]);
const handleToggleTechnicalInfo = useCallback(() => {
setLastOpenedModal("techInfo");
onToggleTechnicalInfo?.();
controlsInteractionRef.current();
}, [onToggleTechnicalInfo]);
const effectiveProgress = useSharedValue(0);
const SEEK_THRESHOLD_MS = 5000;
@@ -763,6 +781,16 @@ export const Controls: FC<Props> = ({
pointerEvents='none'
/>
{getTechnicalInfo && (
<TechnicalInfoOverlay
showControls={showControls}
visible={showTechnicalInfo ?? false}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
/>
)}
{nextItem && (
<TVNextEpisodeCountdown
nextItem={nextItem}
@@ -909,6 +937,16 @@ export const Controls: FC<Props> = ({
hasTVPreferredFocus={!false && lastOpenedModal === "subtitle"}
size={24}
/>
{getTechnicalInfo && (
<TVControlButton
icon='information-circle'
onPress={handleToggleTechnicalInfo}
disabled={false}
hasTVPreferredFocus={!false && lastOpenedModal === "techInfo"}
size={24}
/>
)}
</View>
{showSeekBubble && (

View File

@@ -7,6 +7,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { TVTypography } from "@/constants/TVTypography";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT } from "./constants";
@@ -121,7 +122,7 @@ const formatTranscodeReason = (reason: string): string => {
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
({
showControls,
showControls: _showControls,
visible,
getTechnicalInfo,
playMethod,
@@ -168,64 +169,64 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
opacity: opacity.value,
}));
// Hide on TV platforms
if (Platform.isTV) return null;
// Don't render if not visible
if (!visible) return null;
// TV-specific styles
const containerStyle = Platform.isTV
? {
top: Math.max(insets.top, 48) + 20,
left: Math.max(insets.left, 48) + 20,
}
: {
top:
(settings?.safeAreaInControlsEnabled ?? true)
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
left:
(settings?.safeAreaInControlsEnabled ?? true)
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
};
const textStyle = Platform.isTV ? styles.infoTextTV : styles.infoText;
const reasonStyle = Platform.isTV ? styles.reasonTextTV : styles.reasonText;
const boxStyle = Platform.isTV ? styles.infoBoxTV : styles.infoBox;
return (
<Animated.View
style={[
styles.container,
animatedStyle,
{
top:
(settings?.safeAreaInControlsEnabled ?? true)
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
left:
(settings?.safeAreaInControlsEnabled ?? true)
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
},
]}
style={[styles.container, animatedStyle, containerStyle]}
pointerEvents='none'
>
<View style={styles.infoBox}>
<View style={boxStyle}>
{playMethod && (
<Text
style={[
styles.infoText,
{ color: getPlayMethodColor(playMethod) },
]}
style={[textStyle, { color: getPlayMethodColor(playMethod) }]}
>
{getPlayMethodLabel(playMethod)}
</Text>
)}
{transcodeReasons && transcodeReasons.length > 0 && (
<Text style={[styles.infoText, styles.reasonText]}>
<Text style={[textStyle, reasonStyle]}>
{transcodeReasons.map(formatTranscodeReason).join(", ")}
</Text>
)}
{info?.videoWidth && info?.videoHeight && (
<Text style={styles.infoText}>
<Text style={textStyle}>
{info.videoWidth}x{info.videoHeight}
</Text>
)}
{info?.videoCodec && (
<Text style={styles.infoText}>
<Text style={textStyle}>
Video: {formatCodec(info.videoCodec)}
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
</Text>
)}
{info?.audioCodec && (
<Text style={styles.infoText}>
Audio: {formatCodec(info.audioCodec)}
</Text>
<Text style={textStyle}>Audio: {formatCodec(info.audioCodec)}</Text>
)}
{(info?.videoBitrate || info?.audioBitrate) && (
<Text style={styles.infoText}>
<Text style={textStyle}>
Bitrate:{" "}
{info.videoBitrate
? formatBitrate(info.videoBitrate)
@@ -235,18 +236,16 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
</Text>
)}
{info?.cacheSeconds !== undefined && (
<Text style={styles.infoText}>
<Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s
</Text>
)}
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
<Text style={[styles.infoText, styles.warningText]}>
<Text style={[textStyle, styles.warningText]}>
Dropped: {info.droppedFrames} frames
</Text>
)}
{!info && !playMethod && (
<Text style={styles.infoText}>Loading...</Text>
)}
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
</View>
</Animated.View>
);
@@ -267,12 +266,25 @@ const styles = StyleSheet.create({
paddingVertical: 8,
minWidth: 150,
},
infoBoxTV: {
backgroundColor: "rgba(0, 0, 0, 0.6)",
borderRadius: 12,
paddingHorizontal: 20,
paddingVertical: 16,
minWidth: 250,
},
infoText: {
color: "white",
fontSize: 12,
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
lineHeight: 18,
},
infoTextTV: {
color: "white",
fontSize: TVTypography.body,
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
lineHeight: TVTypography.body * 1.5,
},
warningText: {
color: "#ff9800",
},
@@ -280,4 +292,8 @@ const styles = StyleSheet.create({
color: "#fbbf24",
fontSize: 10,
},
reasonTextTV: {
color: "#fbbf24",
fontSize: TVTypography.callout,
},
});