mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: add technical stream info overlay for MPV player
This commit is contained in:
@@ -82,6 +82,7 @@ export default function page() {
|
||||
const [tracksReady, setTracksReady] = useState(false);
|
||||
const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false);
|
||||
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
|
||||
const [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
@@ -726,6 +727,59 @@ export default function page() {
|
||||
videoRef.current?.seekTo?.(position / 1000);
|
||||
}, []);
|
||||
|
||||
// Technical info toggle handler
|
||||
const handleToggleTechnicalInfo = useCallback(() => {
|
||||
setShowTechnicalInfo((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Get technical info from the player
|
||||
const getTechnicalInfo = useCallback(async () => {
|
||||
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
|
||||
}, []);
|
||||
|
||||
// Determine play method based on stream URL and media source
|
||||
const playMethod = useMemo<
|
||||
"DirectPlay" | "DirectStream" | "Transcode" | undefined
|
||||
>(() => {
|
||||
if (!stream?.url) return undefined;
|
||||
|
||||
// Check if transcoding (m3u8 playlist or TranscodingUrl present)
|
||||
if (stream.url.includes("m3u8") || stream.mediaSource?.TranscodingUrl) {
|
||||
return "Transcode";
|
||||
}
|
||||
|
||||
// Check if direct play (no container remuxing needed)
|
||||
// Direct play means the file is being served as-is
|
||||
if (stream.url.includes("/Videos/") && stream.url.includes("/stream")) {
|
||||
return "DirectStream";
|
||||
}
|
||||
|
||||
// Default to direct play if we're not transcoding
|
||||
return "DirectPlay";
|
||||
}, [stream?.url, stream?.mediaSource?.TranscodingUrl]);
|
||||
|
||||
// Extract transcode reasons from the TranscodingUrl
|
||||
const transcodeReasons = useMemo<string[]>(() => {
|
||||
const transcodingUrl = stream?.mediaSource?.TranscodingUrl;
|
||||
if (!transcodingUrl) return [];
|
||||
|
||||
try {
|
||||
// Parse the TranscodeReasons parameter from the URL
|
||||
const url = new URL(transcodingUrl, "http://localhost");
|
||||
const reasons = url.searchParams.get("TranscodeReasons");
|
||||
if (reasons) {
|
||||
return reasons.split(",").filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
// If URL parsing fails, try regex fallback
|
||||
const match = transcodingUrl.match(/TranscodeReasons=([^&]+)/);
|
||||
if (match) {
|
||||
return match[1].split(",").filter(Boolean);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}, [stream?.mediaSource?.TranscodingUrl]);
|
||||
|
||||
const handleZoomToggle = useCallback(async () => {
|
||||
const newZoomState = !isZoomedToFill;
|
||||
await videoRef.current?.setZoomedToFill?.(newZoomState);
|
||||
@@ -924,6 +978,11 @@ export default function page() {
|
||||
downloadedFiles={downloadedFiles}
|
||||
playbackSpeed={currentPlaybackSpeed}
|
||||
setPlaybackSpeed={handleSetPlaybackSpeed}
|
||||
showTechnicalInfo={showTechnicalInfo}
|
||||
onToggleTechnicalInfo={handleToggleTechnicalInfo}
|
||||
getTechnicalInfo={getTechnicalInfo}
|
||||
playMethod={playMethod}
|
||||
transcodeReasons={transcodeReasons}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -25,7 +25,14 @@ export type ToggleOption = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type Option = RadioOption | ToggleOption;
|
||||
export type ActionOption = {
|
||||
type: "action";
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type Option = RadioOption | ToggleOption | ActionOption;
|
||||
|
||||
// Option group structure
|
||||
export type OptionGroup = {
|
||||
@@ -64,7 +71,10 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||
isLast,
|
||||
}) => {
|
||||
const isToggle = option.type === "toggle";
|
||||
const handlePress = isToggle ? option.onToggle : option.onPress;
|
||||
const isAction = option.type === "action";
|
||||
const handlePress = isToggle
|
||||
? option.onToggle
|
||||
: (option as RadioOption | ActionOption).onPress;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -76,7 +86,7 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||
<Text className='flex-1 text-white'>{option.label}</Text>
|
||||
{isToggle ? (
|
||||
<ToggleSwitch value={option.value} />
|
||||
) : option.selected ? (
|
||||
) : isAction ? null : (option as RadioOption).selected ? (
|
||||
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
|
||||
) : (
|
||||
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
|
||||
@@ -150,6 +160,15 @@ const BottomSheetContent: React.FC<{
|
||||
},
|
||||
};
|
||||
}
|
||||
if (option.type === "action") {
|
||||
return {
|
||||
...option,
|
||||
onPress: () => {
|
||||
option.onPress();
|
||||
onClose?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
return option;
|
||||
}),
|
||||
}));
|
||||
@@ -225,6 +244,9 @@ const PlatformDropdownComponent = ({
|
||||
const toggleOptions = group.options.filter(
|
||||
(opt) => opt.type === "toggle",
|
||||
) as ToggleOption[];
|
||||
const actionOptions = group.options.filter(
|
||||
(opt) => opt.type === "action",
|
||||
) as ActionOption[];
|
||||
|
||||
const items = [];
|
||||
|
||||
@@ -291,6 +313,21 @@ const PlatformDropdownComponent = ({
|
||||
);
|
||||
});
|
||||
|
||||
// Add Buttons for action options (no icon)
|
||||
actionOptions.forEach((option, optionIndex) => {
|
||||
items.push(
|
||||
<Button
|
||||
key={`action-${groupIndex}-${optionIndex}`}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</Button>,
|
||||
);
|
||||
});
|
||||
|
||||
return items;
|
||||
})}
|
||||
</ContextMenu.Items>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -36,6 +37,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||
import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
||||
import { useVideoSlider } from "./hooks/useVideoSlider";
|
||||
import { useVideoTime } from "./hooks/useVideoTime";
|
||||
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
@@ -64,6 +66,12 @@ interface Props {
|
||||
// Playback speed props
|
||||
playbackSpeed?: number;
|
||||
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
|
||||
// Technical info props
|
||||
showTechnicalInfo?: boolean;
|
||||
onToggleTechnicalInfo?: () => void;
|
||||
getTechnicalInfo?: () => Promise<TechnicalInfo>;
|
||||
playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
|
||||
transcodeReasons?: string[];
|
||||
}
|
||||
|
||||
export const Controls: FC<Props> = ({
|
||||
@@ -88,6 +96,11 @@ export const Controls: FC<Props> = ({
|
||||
downloadedFiles = undefined,
|
||||
playbackSpeed = 1.0,
|
||||
setPlaybackSpeed,
|
||||
showTechnicalInfo = false,
|
||||
onToggleTechnicalInfo,
|
||||
getTechnicalInfo,
|
||||
playMethod,
|
||||
transcodeReasons,
|
||||
}) => {
|
||||
const offline = useOfflineMode();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
@@ -460,6 +473,16 @@ export const Controls: FC<Props> = ({
|
||||
onSkipForward={handleSkipForward}
|
||||
onSkipBackward={handleSkipBackward}
|
||||
/>
|
||||
{/* Technical Info Overlay - rendered outside animated views to stay visible */}
|
||||
{getTechnicalInfo && (
|
||||
<TechnicalInfoOverlay
|
||||
showControls={showControls}
|
||||
visible={showTechnicalInfo}
|
||||
getTechnicalInfo={getTechnicalInfo}
|
||||
playMethod={playMethod}
|
||||
transcodeReasons={transcodeReasons}
|
||||
/>
|
||||
)}
|
||||
<Animated.View
|
||||
style={headerAnimatedStyle}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
@@ -480,6 +503,8 @@ export const Controls: FC<Props> = ({
|
||||
onZoomToggle={onZoomToggle}
|
||||
playbackSpeed={playbackSpeed}
|
||||
setPlaybackSpeed={setPlaybackSpeed}
|
||||
showTechnicalInfo={showTechnicalInfo}
|
||||
onToggleTechnicalInfo={onToggleTechnicalInfo}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
|
||||
@@ -34,6 +34,9 @@ interface HeaderControlsProps {
|
||||
// Playback speed props
|
||||
playbackSpeed?: number;
|
||||
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
|
||||
// Technical info props
|
||||
showTechnicalInfo?: boolean;
|
||||
onToggleTechnicalInfo?: () => void;
|
||||
}
|
||||
|
||||
export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
@@ -52,6 +55,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
onZoomToggle,
|
||||
playbackSpeed = 1.0,
|
||||
setPlaybackSpeed,
|
||||
showTechnicalInfo = false,
|
||||
onToggleTechnicalInfo,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
@@ -110,6 +115,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
<DropdownView
|
||||
playbackSpeed={playbackSpeed}
|
||||
setPlaybackSpeed={setPlaybackSpeed}
|
||||
showTechnicalInfo={showTechnicalInfo}
|
||||
onToggleTechnicalInfo={onToggleTechnicalInfo}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
283
components/video-player/controls/TechnicalInfoOverlay.tsx
Normal file
283
components/video-player/controls/TechnicalInfoOverlay.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { type FC, memo, useCallback, useEffect, useState } from "react";
|
||||
import { Platform, StyleSheet, Text, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT } from "./constants";
|
||||
|
||||
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
||||
|
||||
interface TechnicalInfoOverlayProps {
|
||||
showControls: boolean;
|
||||
visible: boolean;
|
||||
getTechnicalInfo: () => Promise<TechnicalInfo>;
|
||||
playMethod?: PlayMethod;
|
||||
transcodeReasons?: string[];
|
||||
}
|
||||
|
||||
const formatBitrate = (bitsPerSecond: number): string => {
|
||||
const mbps = bitsPerSecond / 1_000_000;
|
||||
if (mbps >= 1) {
|
||||
return `${mbps.toFixed(1)} Mbps`;
|
||||
}
|
||||
const kbps = bitsPerSecond / 1_000;
|
||||
return `${kbps.toFixed(0)} Kbps`;
|
||||
};
|
||||
|
||||
const formatCodec = (codec: string): string => {
|
||||
// Normalize common codec names
|
||||
const codecMap: Record<string, string> = {
|
||||
h264: "H.264",
|
||||
hevc: "HEVC",
|
||||
h265: "HEVC",
|
||||
vp9: "VP9",
|
||||
vp8: "VP8",
|
||||
av1: "AV1",
|
||||
aac: "AAC",
|
||||
ac3: "AC3",
|
||||
eac3: "E-AC3",
|
||||
dts: "DTS",
|
||||
truehd: "TrueHD",
|
||||
flac: "FLAC",
|
||||
opus: "Opus",
|
||||
mp3: "MP3",
|
||||
};
|
||||
return codecMap[codec.toLowerCase()] || codec.toUpperCase();
|
||||
};
|
||||
|
||||
const formatFps = (fps: number): string => {
|
||||
// Common frame rates
|
||||
if (Math.abs(fps - 23.976) < 0.01) return "23.976";
|
||||
if (Math.abs(fps - 29.97) < 0.01) return "29.97";
|
||||
if (Math.abs(fps - 59.94) < 0.01) return "59.94";
|
||||
if (Number.isInteger(fps)) return fps.toString();
|
||||
return fps.toFixed(2);
|
||||
};
|
||||
|
||||
const getPlayMethodLabel = (method: PlayMethod): string => {
|
||||
switch (method) {
|
||||
case "DirectPlay":
|
||||
return "Direct Play";
|
||||
case "DirectStream":
|
||||
return "Direct Stream";
|
||||
case "Transcode":
|
||||
return "Transcoding";
|
||||
default:
|
||||
return method;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlayMethodColor = (method: PlayMethod): string => {
|
||||
switch (method) {
|
||||
case "DirectPlay":
|
||||
return "#4ade80"; // green
|
||||
case "DirectStream":
|
||||
return "#60a5fa"; // blue
|
||||
case "Transcode":
|
||||
return "#fbbf24"; // yellow/amber
|
||||
default:
|
||||
return "white";
|
||||
}
|
||||
};
|
||||
|
||||
const formatTranscodeReason = (reason: string): string => {
|
||||
// Convert camelCase/PascalCase to readable format
|
||||
const reasonMap: Record<string, string> = {
|
||||
ContainerNotSupported: "Container not supported",
|
||||
VideoCodecNotSupported: "Video codec not supported",
|
||||
AudioCodecNotSupported: "Audio codec not supported",
|
||||
SubtitleCodecNotSupported: "Subtitle codec not supported",
|
||||
AudioIsExternal: "Audio is external",
|
||||
SecondaryAudioNotSupported: "Secondary audio not supported",
|
||||
VideoProfileNotSupported: "Video profile not supported",
|
||||
VideoLevelNotSupported: "Video level not supported",
|
||||
VideoResolutionNotSupported: "Resolution not supported",
|
||||
VideoBitDepthNotSupported: "Bit depth not supported",
|
||||
VideoFramerateNotSupported: "Framerate not supported",
|
||||
RefFramesNotSupported: "Ref frames not supported",
|
||||
AnamorphicVideoNotSupported: "Anamorphic video not supported",
|
||||
InterlacedVideoNotSupported: "Interlaced video not supported",
|
||||
AudioChannelsNotSupported: "Audio channels not supported",
|
||||
AudioProfileNotSupported: "Audio profile not supported",
|
||||
AudioSampleRateNotSupported: "Sample rate not supported",
|
||||
AudioBitDepthNotSupported: "Audio bit depth not supported",
|
||||
ContainerBitrateExceedsLimit: "Bitrate exceeds limit",
|
||||
VideoBitrateNotSupported: "Video bitrate not supported",
|
||||
AudioBitrateNotSupported: "Audio bitrate not supported",
|
||||
UnknownVideoStreamInfo: "Unknown video stream",
|
||||
UnknownAudioStreamInfo: "Unknown audio stream",
|
||||
DirectPlayError: "Direct play error",
|
||||
VideoRangeTypeNotSupported: "HDR not supported",
|
||||
VideoCodecTagNotSupported: "Video codec tag not supported",
|
||||
};
|
||||
return reasonMap[reason] || reason;
|
||||
};
|
||||
|
||||
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
({
|
||||
showControls,
|
||||
visible,
|
||||
getTechnicalInfo,
|
||||
playMethod,
|
||||
transcodeReasons,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||
|
||||
const opacity = useSharedValue(0);
|
||||
|
||||
// Animate visibility based on visible prop only (stays visible regardless of controls)
|
||||
useEffect(() => {
|
||||
opacity.value = withTiming(visible ? 1 : 0, {
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.quad),
|
||||
});
|
||||
}, [visible, opacity]);
|
||||
|
||||
// Fetch technical info periodically when visible
|
||||
const fetchInfo = useCallback(async () => {
|
||||
try {
|
||||
const data = await getTechnicalInfo();
|
||||
setInfo(data);
|
||||
} catch (_error) {
|
||||
// Silently fail - the info is optional
|
||||
}
|
||||
}, [getTechnicalInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch immediately
|
||||
fetchInfo();
|
||||
|
||||
// Then fetch every 2 seconds
|
||||
const interval = setInterval(fetchInfo, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, fetchInfo]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
// Don't render if not visible
|
||||
if (!visible) return null;
|
||||
|
||||
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,
|
||||
},
|
||||
]}
|
||||
pointerEvents='none'
|
||||
>
|
||||
<View style={styles.infoBox}>
|
||||
{playMethod && (
|
||||
<Text
|
||||
style={[
|
||||
styles.infoText,
|
||||
{ color: getPlayMethodColor(playMethod) },
|
||||
]}
|
||||
>
|
||||
{getPlayMethodLabel(playMethod)}
|
||||
</Text>
|
||||
)}
|
||||
{transcodeReasons && transcodeReasons.length > 0 && (
|
||||
<Text style={[styles.infoText, styles.reasonText]}>
|
||||
{transcodeReasons.map(formatTranscodeReason).join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
{info?.videoWidth && info?.videoHeight && (
|
||||
<Text style={styles.infoText}>
|
||||
{info.videoWidth}x{info.videoHeight}
|
||||
</Text>
|
||||
)}
|
||||
{info?.videoCodec && (
|
||||
<Text style={styles.infoText}>
|
||||
Video: {formatCodec(info.videoCodec)}
|
||||
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.audioCodec && (
|
||||
<Text style={styles.infoText}>
|
||||
Audio: {formatCodec(info.audioCodec)}
|
||||
</Text>
|
||||
)}
|
||||
{(info?.videoBitrate || info?.audioBitrate) && (
|
||||
<Text style={styles.infoText}>
|
||||
Bitrate:{" "}
|
||||
{info.videoBitrate
|
||||
? formatBitrate(info.videoBitrate)
|
||||
: info.audioBitrate
|
||||
? formatBitrate(info.audioBitrate)
|
||||
: "N/A"}
|
||||
</Text>
|
||||
)}
|
||||
{info?.cacheSeconds !== undefined && (
|
||||
<Text style={styles.infoText}>
|
||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||
</Text>
|
||||
)}
|
||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||
<Text style={[styles.infoText, styles.warningText]}>
|
||||
Dropped: {info.droppedFrames} frames
|
||||
</Text>
|
||||
)}
|
||||
{!info && !playMethod && (
|
||||
<Text style={styles.infoText}>Loading...</Text>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TechnicalInfoOverlay.displayName = "TechnicalInfoOverlay";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: "absolute",
|
||||
zIndex: 15,
|
||||
},
|
||||
infoBox: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
minWidth: 150,
|
||||
},
|
||||
infoText: {
|
||||
color: "white",
|
||||
fontSize: 12,
|
||||
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
|
||||
lineHeight: 18,
|
||||
},
|
||||
warningText: {
|
||||
color: "#ff9800",
|
||||
},
|
||||
reasonText: {
|
||||
color: "#fbbf24",
|
||||
fontSize: 10,
|
||||
},
|
||||
});
|
||||
@@ -30,11 +30,15 @@ const SUBTITLE_SIZE_PRESETS = [
|
||||
interface DropdownViewProps {
|
||||
playbackSpeed?: number;
|
||||
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
|
||||
showTechnicalInfo?: boolean;
|
||||
onToggleTechnicalInfo?: () => void;
|
||||
}
|
||||
|
||||
const DropdownView = ({
|
||||
playbackSpeed = 1.0,
|
||||
setPlaybackSpeed,
|
||||
showTechnicalInfo = false,
|
||||
onToggleTechnicalInfo,
|
||||
}: DropdownViewProps) => {
|
||||
const { subtitleTracks, audioTracks } = useVideoContext();
|
||||
const { item, mediaSource } = usePlayerContext();
|
||||
@@ -161,6 +165,21 @@ const DropdownView = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Technical Info (at bottom)
|
||||
if (onToggleTechnicalInfo) {
|
||||
groups.push({
|
||||
options: [
|
||||
{
|
||||
type: "action" as const,
|
||||
label: showTechnicalInfo
|
||||
? "Hide Technical Info"
|
||||
: "Show Technical Info",
|
||||
onPress: onToggleTechnicalInfo,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
@@ -175,6 +194,8 @@ const DropdownView = ({
|
||||
updateSettings,
|
||||
playbackSpeed,
|
||||
setPlaybackSpeed,
|
||||
showTechnicalInfo,
|
||||
onToggleTechnicalInfo,
|
||||
// Note: subtitleTracks and audioTracks are intentionally excluded
|
||||
// because we use subtitleTracksKey and audioTracksKey for stability
|
||||
]);
|
||||
|
||||
@@ -430,6 +430,57 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
MPVLib.setPropertyDouble("panscan", panscanValue)
|
||||
}
|
||||
|
||||
// MARK: - Technical Info
|
||||
|
||||
fun getTechnicalInfo(): Map<String, Any> {
|
||||
val info = mutableMapOf<String, Any>()
|
||||
|
||||
// Video dimensions
|
||||
MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
||||
info["videoWidth"] = it
|
||||
}
|
||||
MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
||||
info["videoHeight"] = it
|
||||
}
|
||||
|
||||
// Video codec
|
||||
MPVLib.getPropertyString("video-format")?.let {
|
||||
info["videoCodec"] = it
|
||||
}
|
||||
|
||||
// Audio codec
|
||||
MPVLib.getPropertyString("audio-codec-name")?.let {
|
||||
info["audioCodec"] = it
|
||||
}
|
||||
|
||||
// FPS (container fps)
|
||||
MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
||||
info["fps"] = it
|
||||
}
|
||||
|
||||
// Video bitrate (bits per second)
|
||||
MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
||||
info["videoBitrate"] = it
|
||||
}
|
||||
|
||||
// Audio bitrate (bits per second)
|
||||
MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
||||
info["audioBitrate"] = it
|
||||
}
|
||||
|
||||
// Demuxer cache duration (seconds of video buffered)
|
||||
MPVLib.getPropertyDouble("demuxer-cache-duration")?.let {
|
||||
info["cacheSeconds"] = it
|
||||
}
|
||||
|
||||
// Dropped frames
|
||||
MPVLib.getPropertyInt("frame-drop-count")?.let {
|
||||
info["droppedFrames"] = it
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// MARK: - MPVLib.EventObserver
|
||||
|
||||
override fun eventProperty(property: String) {
|
||||
|
||||
@@ -173,6 +173,11 @@ class MpvPlayerModule : Module() {
|
||||
view.isZoomedToFill()
|
||||
}
|
||||
|
||||
// Technical info function
|
||||
AsyncFunction("getTechnicalInfo") { view: MpvPlayerView ->
|
||||
view.getTechnicalInfo()
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||
}
|
||||
|
||||
@@ -330,6 +330,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
return _isZoomedToFill
|
||||
}
|
||||
|
||||
// MARK: - Technical Info
|
||||
|
||||
fun getTechnicalInfo(): Map<String, Any> {
|
||||
return renderer?.getTechnicalInfo() ?: emptyMap()
|
||||
}
|
||||
|
||||
// MARK: - MPVLayerRenderer.Delegate
|
||||
|
||||
override fun onPositionChanged(position: Double, duration: Double) {
|
||||
|
||||
@@ -762,4 +762,64 @@ final class MPVLayerRenderer {
|
||||
getProperty(handle: handle, name: "aid", format: MPV_FORMAT_INT64, value: &aid)
|
||||
return Int(aid)
|
||||
}
|
||||
|
||||
// MARK: - Technical Info
|
||||
|
||||
func getTechnicalInfo() -> [String: Any] {
|
||||
guard let handle = mpv else { return [:] }
|
||||
|
||||
var info: [String: Any] = [:]
|
||||
|
||||
// Video dimensions
|
||||
var videoWidth: Int64 = 0
|
||||
var videoHeight: Int64 = 0
|
||||
if getProperty(handle: handle, name: "video-params/w", format: MPV_FORMAT_INT64, value: &videoWidth) >= 0 {
|
||||
info["videoWidth"] = Int(videoWidth)
|
||||
}
|
||||
if getProperty(handle: handle, name: "video-params/h", format: MPV_FORMAT_INT64, value: &videoHeight) >= 0 {
|
||||
info["videoHeight"] = Int(videoHeight)
|
||||
}
|
||||
|
||||
// Video codec
|
||||
if let videoCodec = getStringProperty(handle: handle, name: "video-format") {
|
||||
info["videoCodec"] = videoCodec
|
||||
}
|
||||
|
||||
// Audio codec
|
||||
if let audioCodec = getStringProperty(handle: handle, name: "audio-codec-name") {
|
||||
info["audioCodec"] = audioCodec
|
||||
}
|
||||
|
||||
// FPS (container fps)
|
||||
var fps: Double = 0
|
||||
if getProperty(handle: handle, name: "container-fps", format: MPV_FORMAT_DOUBLE, value: &fps) >= 0 && fps > 0 {
|
||||
info["fps"] = fps
|
||||
}
|
||||
|
||||
// Video bitrate (bits per second)
|
||||
var videoBitrate: Int64 = 0
|
||||
if getProperty(handle: handle, name: "video-bitrate", format: MPV_FORMAT_INT64, value: &videoBitrate) >= 0 && videoBitrate > 0 {
|
||||
info["videoBitrate"] = Int(videoBitrate)
|
||||
}
|
||||
|
||||
// Audio bitrate (bits per second)
|
||||
var audioBitrate: Int64 = 0
|
||||
if getProperty(handle: handle, name: "audio-bitrate", format: MPV_FORMAT_INT64, value: &audioBitrate) >= 0 && audioBitrate > 0 {
|
||||
info["audioBitrate"] = Int(audioBitrate)
|
||||
}
|
||||
|
||||
// Demuxer cache duration (seconds of video buffered)
|
||||
var cacheSeconds: Double = 0
|
||||
if getProperty(handle: handle, name: "demuxer-cache-duration", format: MPV_FORMAT_DOUBLE, value: &cacheSeconds) >= 0 {
|
||||
info["cacheSeconds"] = cacheSeconds
|
||||
}
|
||||
|
||||
// Dropped frames
|
||||
var droppedFrames: Int64 = 0
|
||||
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
|
||||
info["droppedFrames"] = Int(droppedFrames)
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,11 @@ public class MpvPlayerModule: Module {
|
||||
return view.isZoomedToFill()
|
||||
}
|
||||
|
||||
// Technical info function
|
||||
AsyncFunction("getTechnicalInfo") { (view: MpvPlayerView) -> [String: Any] in
|
||||
return view.getTechnicalInfo()
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||
}
|
||||
|
||||
@@ -282,6 +282,12 @@ class MpvPlayerView: ExpoView {
|
||||
return _isZoomedToFill
|
||||
}
|
||||
|
||||
// MARK: - Technical Info
|
||||
|
||||
func getTechnicalInfo() -> [String: Any] {
|
||||
return renderer?.getTechnicalInfo() ?? [:]
|
||||
}
|
||||
|
||||
deinit {
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
|
||||
@@ -89,6 +89,8 @@ export interface MpvPlayerViewRef {
|
||||
// Video scaling
|
||||
setZoomedToFill: (zoomed: boolean) => Promise<void>;
|
||||
isZoomedToFill: () => Promise<boolean>;
|
||||
// Technical info
|
||||
getTechnicalInfo: () => Promise<TechnicalInfo>;
|
||||
}
|
||||
|
||||
export type SubtitleTrack = {
|
||||
@@ -106,3 +108,15 @@ export type AudioTrack = {
|
||||
channels?: number;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export type TechnicalInfo = {
|
||||
videoWidth?: number;
|
||||
videoHeight?: number;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
fps?: number;
|
||||
videoBitrate?: number;
|
||||
audioBitrate?: number;
|
||||
cacheSeconds?: number;
|
||||
droppedFrames?: number;
|
||||
};
|
||||
|
||||
@@ -101,6 +101,10 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
isZoomedToFill: async () => {
|
||||
return await nativeRef.current?.isZoomedToFill();
|
||||
},
|
||||
// Technical info
|
||||
getTechnicalInfo: async () => {
|
||||
return await nativeRef.current?.getTechnicalInfo();
|
||||
},
|
||||
}));
|
||||
|
||||
return <NativeView ref={nativeRef} {...props} />;
|
||||
|
||||
Reference in New Issue
Block a user