mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-12 08:50:25 +01:00
feat(tv): add technical info overlay to player controls
This commit is contained in:
@@ -1175,6 +1175,11 @@ export default function page() {
|
|||||||
goToNextItem={goToNextItem}
|
goToNextItem={goToNextItem}
|
||||||
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
|
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
|
||||||
addSubtitleFile={addSubtitleFile}
|
addSubtitleFile={addSubtitleFile}
|
||||||
|
showTechnicalInfo={showTechnicalInfo}
|
||||||
|
onToggleTechnicalInfo={handleToggleTechnicalInfo}
|
||||||
|
getTechnicalInfo={getTechnicalInfo}
|
||||||
|
playMethod={playMethod}
|
||||||
|
transcodeReasons={transcodeReasons}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Controls
|
<Controls
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
|||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||||
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
@@ -40,6 +41,7 @@ import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
|||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
import { useRemoteControl } from "./hooks/useRemoteControl";
|
import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||||
import { useVideoTime } from "./hooks/useVideoTime";
|
import { useVideoTime } from "./hooks/useVideoTime";
|
||||||
|
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
||||||
import { TrickplayBubble } from "./TrickplayBubble";
|
import { TrickplayBubble } from "./TrickplayBubble";
|
||||||
import { useControlsTimeout } from "./useControlsTimeout";
|
import { useControlsTimeout } from "./useControlsTimeout";
|
||||||
|
|
||||||
@@ -69,6 +71,11 @@ interface Props {
|
|||||||
import("@jellyfin/sdk/lib/generated-client").MediaStream[]
|
import("@jellyfin/sdk/lib/generated-client").MediaStream[]
|
||||||
>;
|
>;
|
||||||
addSubtitleFile?: (path: string) => void;
|
addSubtitleFile?: (path: string) => void;
|
||||||
|
showTechnicalInfo?: boolean;
|
||||||
|
onToggleTechnicalInfo?: () => void;
|
||||||
|
getTechnicalInfo?: () => Promise<TechnicalInfo>;
|
||||||
|
playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
|
||||||
|
transcodeReasons?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TV_SEEKBAR_HEIGHT = 14;
|
const TV_SEEKBAR_HEIGHT = 14;
|
||||||
@@ -97,6 +104,11 @@ export const Controls: FC<Props> = ({
|
|||||||
goToNextItem: goToNextItemProp,
|
goToNextItem: goToNextItemProp,
|
||||||
onRefreshSubtitleTracks,
|
onRefreshSubtitleTracks,
|
||||||
addSubtitleFile,
|
addSubtitleFile,
|
||||||
|
showTechnicalInfo,
|
||||||
|
onToggleTechnicalInfo,
|
||||||
|
getTechnicalInfo,
|
||||||
|
playMethod,
|
||||||
|
transcodeReasons,
|
||||||
}) => {
|
}) => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -127,7 +139,7 @@ export const Controls: FC<Props> = ({
|
|||||||
const { showSubtitleModal } = useTVSubtitleModal();
|
const { showSubtitleModal } = useTVSubtitleModal();
|
||||||
|
|
||||||
// Track which button should have preferred focus when controls show
|
// 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);
|
const [lastOpenedModal, setLastOpenedModal] = useState<LastModalType>(null);
|
||||||
|
|
||||||
// Track if play button should have focus (when showing controls via up/down D-pad)
|
// 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,
|
onRefreshSubtitleTracks,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleToggleTechnicalInfo = useCallback(() => {
|
||||||
|
setLastOpenedModal("techInfo");
|
||||||
|
onToggleTechnicalInfo?.();
|
||||||
|
controlsInteractionRef.current();
|
||||||
|
}, [onToggleTechnicalInfo]);
|
||||||
|
|
||||||
const effectiveProgress = useSharedValue(0);
|
const effectiveProgress = useSharedValue(0);
|
||||||
|
|
||||||
const SEEK_THRESHOLD_MS = 5000;
|
const SEEK_THRESHOLD_MS = 5000;
|
||||||
@@ -763,6 +781,16 @@ export const Controls: FC<Props> = ({
|
|||||||
pointerEvents='none'
|
pointerEvents='none'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{getTechnicalInfo && (
|
||||||
|
<TechnicalInfoOverlay
|
||||||
|
showControls={showControls}
|
||||||
|
visible={showTechnicalInfo ?? false}
|
||||||
|
getTechnicalInfo={getTechnicalInfo}
|
||||||
|
playMethod={playMethod}
|
||||||
|
transcodeReasons={transcodeReasons}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{nextItem && (
|
{nextItem && (
|
||||||
<TVNextEpisodeCountdown
|
<TVNextEpisodeCountdown
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
@@ -909,6 +937,16 @@ export const Controls: FC<Props> = ({
|
|||||||
hasTVPreferredFocus={!false && lastOpenedModal === "subtitle"}
|
hasTVPreferredFocus={!false && lastOpenedModal === "subtitle"}
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{getTechnicalInfo && (
|
||||||
|
<TVControlButton
|
||||||
|
icon='information-circle'
|
||||||
|
onPress={handleToggleTechnicalInfo}
|
||||||
|
disabled={false}
|
||||||
|
hasTVPreferredFocus={!false && lastOpenedModal === "techInfo"}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{showSeekBubble && (
|
{showSeekBubble && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { HEADER_LAYOUT } from "./constants";
|
import { HEADER_LAYOUT } from "./constants";
|
||||||
@@ -121,7 +122,7 @@ const formatTranscodeReason = (reason: string): string => {
|
|||||||
|
|
||||||
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||||
({
|
({
|
||||||
showControls,
|
showControls: _showControls,
|
||||||
visible,
|
visible,
|
||||||
getTechnicalInfo,
|
getTechnicalInfo,
|
||||||
playMethod,
|
playMethod,
|
||||||
@@ -168,64 +169,64 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
opacity: opacity.value,
|
opacity: opacity.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Hide on TV platforms
|
|
||||||
if (Platform.isTV) return null;
|
|
||||||
|
|
||||||
// Don't render if not visible
|
// Don't render if not visible
|
||||||
if (!visible) return null;
|
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 (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[styles.container, animatedStyle, containerStyle]}
|
||||||
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,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
pointerEvents='none'
|
pointerEvents='none'
|
||||||
>
|
>
|
||||||
<View style={styles.infoBox}>
|
<View style={boxStyle}>
|
||||||
{playMethod && (
|
{playMethod && (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[textStyle, { color: getPlayMethodColor(playMethod) }]}
|
||||||
styles.infoText,
|
|
||||||
{ color: getPlayMethodColor(playMethod) },
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{getPlayMethodLabel(playMethod)}
|
{getPlayMethodLabel(playMethod)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{transcodeReasons && transcodeReasons.length > 0 && (
|
{transcodeReasons && transcodeReasons.length > 0 && (
|
||||||
<Text style={[styles.infoText, styles.reasonText]}>
|
<Text style={[textStyle, reasonStyle]}>
|
||||||
{transcodeReasons.map(formatTranscodeReason).join(", ")}
|
{transcodeReasons.map(formatTranscodeReason).join(", ")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.videoWidth && info?.videoHeight && (
|
{info?.videoWidth && info?.videoHeight && (
|
||||||
<Text style={styles.infoText}>
|
<Text style={textStyle}>
|
||||||
{info.videoWidth}x{info.videoHeight}
|
{info.videoWidth}x{info.videoHeight}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.videoCodec && (
|
{info?.videoCodec && (
|
||||||
<Text style={styles.infoText}>
|
<Text style={textStyle}>
|
||||||
Video: {formatCodec(info.videoCodec)}
|
Video: {formatCodec(info.videoCodec)}
|
||||||
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.audioCodec && (
|
{info?.audioCodec && (
|
||||||
<Text style={styles.infoText}>
|
<Text style={textStyle}>Audio: {formatCodec(info.audioCodec)}</Text>
|
||||||
Audio: {formatCodec(info.audioCodec)}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
{(info?.videoBitrate || info?.audioBitrate) && (
|
{(info?.videoBitrate || info?.audioBitrate) && (
|
||||||
<Text style={styles.infoText}>
|
<Text style={textStyle}>
|
||||||
Bitrate:{" "}
|
Bitrate:{" "}
|
||||||
{info.videoBitrate
|
{info.videoBitrate
|
||||||
? formatBitrate(info.videoBitrate)
|
? formatBitrate(info.videoBitrate)
|
||||||
@@ -235,18 +236,16 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.cacheSeconds !== undefined && (
|
{info?.cacheSeconds !== undefined && (
|
||||||
<Text style={styles.infoText}>
|
<Text style={textStyle}>
|
||||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||||
<Text style={[styles.infoText, styles.warningText]}>
|
<Text style={[textStyle, styles.warningText]}>
|
||||||
Dropped: {info.droppedFrames} frames
|
Dropped: {info.droppedFrames} frames
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{!info && !playMethod && (
|
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
|
||||||
<Text style={styles.infoText}>Loading...</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
@@ -267,12 +266,25 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
},
|
},
|
||||||
|
infoBoxTV: {
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 16,
|
||||||
|
minWidth: 250,
|
||||||
|
},
|
||||||
infoText: {
|
infoText: {
|
||||||
color: "white",
|
color: "white",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
|
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
|
infoTextTV: {
|
||||||
|
color: "white",
|
||||||
|
fontSize: TVTypography.body,
|
||||||
|
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
|
||||||
|
lineHeight: TVTypography.body * 1.5,
|
||||||
|
},
|
||||||
warningText: {
|
warningText: {
|
||||||
color: "#ff9800",
|
color: "#ff9800",
|
||||||
},
|
},
|
||||||
@@ -280,4 +292,8 @@ const styles = StyleSheet.create({
|
|||||||
color: "#fbbf24",
|
color: "#fbbf24",
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
},
|
},
|
||||||
|
reasonTextTV: {
|
||||||
|
color: "#fbbf24",
|
||||||
|
fontSize: TVTypography.callout,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user