feat: add technical stream info overlay for MPV player

This commit is contained in:
Fredrik Burmester
2026-01-12 09:05:15 +01:00
parent 3da4b42ca3
commit b0bb6c6c9a
14 changed files with 586 additions and 3 deletions

View File

@@ -82,6 +82,7 @@ export default function page() {
const [tracksReady, setTracksReady] = useState(false); const [tracksReady, setTracksReady] = useState(false);
const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false); const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false);
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0); const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
const [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
@@ -726,6 +727,59 @@ export default function page() {
videoRef.current?.seekTo?.(position / 1000); 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 handleZoomToggle = useCallback(async () => {
const newZoomState = !isZoomedToFill; const newZoomState = !isZoomedToFill;
await videoRef.current?.setZoomedToFill?.(newZoomState); await videoRef.current?.setZoomedToFill?.(newZoomState);
@@ -924,6 +978,11 @@ export default function page() {
downloadedFiles={downloadedFiles} downloadedFiles={downloadedFiles}
playbackSpeed={currentPlaybackSpeed} playbackSpeed={currentPlaybackSpeed}
setPlaybackSpeed={handleSetPlaybackSpeed} setPlaybackSpeed={handleSetPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={handleToggleTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
/> />
)} )}
</View> </View>

View File

@@ -25,7 +25,14 @@ export type ToggleOption = {
disabled?: boolean; 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 // Option group structure
export type OptionGroup = { export type OptionGroup = {
@@ -64,7 +71,10 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
isLast, isLast,
}) => { }) => {
const isToggle = option.type === "toggle"; 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 ( return (
<> <>
@@ -76,7 +86,7 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
<Text className='flex-1 text-white'>{option.label}</Text> <Text className='flex-1 text-white'>{option.label}</Text>
{isToggle ? ( {isToggle ? (
<ToggleSwitch value={option.value} /> <ToggleSwitch value={option.value} />
) : option.selected ? ( ) : isAction ? null : (option as RadioOption).selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' /> <Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : ( ) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' /> <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; return option;
}), }),
})); }));
@@ -225,6 +244,9 @@ const PlatformDropdownComponent = ({
const toggleOptions = group.options.filter( const toggleOptions = group.options.filter(
(opt) => opt.type === "toggle", (opt) => opt.type === "toggle",
) as ToggleOption[]; ) as ToggleOption[];
const actionOptions = group.options.filter(
(opt) => opt.type === "action",
) as ActionOption[];
const items = []; 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; return items;
})} })}
</ContextMenu.Items> </ContextMenu.Items>

View File

@@ -21,6 +21,7 @@ import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay"; import { useTrickplay } from "@/hooks/useTrickplay";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { DownloadedItem } from "@/providers/Downloads/types"; import { DownloadedItem } from "@/providers/Downloads/types";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -36,6 +37,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoNavigation } from "./hooks/useVideoNavigation"; import { useVideoNavigation } from "./hooks/useVideoNavigation";
import { useVideoSlider } from "./hooks/useVideoSlider"; import { useVideoSlider } from "./hooks/useVideoSlider";
import { useVideoTime } from "./hooks/useVideoTime"; import { useVideoTime } from "./hooks/useVideoTime";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { useControlsTimeout } from "./useControlsTimeout"; import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector"; import { type AspectRatio } from "./VideoScalingModeSelector";
@@ -64,6 +66,12 @@ interface Props {
// Playback speed props // Playback speed props
playbackSpeed?: number; playbackSpeed?: number;
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; 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> = ({ export const Controls: FC<Props> = ({
@@ -88,6 +96,11 @@ export const Controls: FC<Props> = ({
downloadedFiles = undefined, downloadedFiles = undefined,
playbackSpeed = 1.0, playbackSpeed = 1.0,
setPlaybackSpeed, setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
getTechnicalInfo,
playMethod,
transcodeReasons,
}) => { }) => {
const offline = useOfflineMode(); const offline = useOfflineMode();
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
@@ -460,6 +473,16 @@ export const Controls: FC<Props> = ({
onSkipForward={handleSkipForward} onSkipForward={handleSkipForward}
onSkipBackward={handleSkipBackward} 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 <Animated.View
style={headerAnimatedStyle} style={headerAnimatedStyle}
pointerEvents={showControls ? "auto" : "none"} pointerEvents={showControls ? "auto" : "none"}
@@ -480,6 +503,8 @@ export const Controls: FC<Props> = ({
onZoomToggle={onZoomToggle} onZoomToggle={onZoomToggle}
playbackSpeed={playbackSpeed} playbackSpeed={playbackSpeed}
setPlaybackSpeed={setPlaybackSpeed} setPlaybackSpeed={setPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={onToggleTechnicalInfo}
/> />
</Animated.View> </Animated.View>
<Animated.View <Animated.View

View File

@@ -34,6 +34,9 @@ interface HeaderControlsProps {
// Playback speed props // Playback speed props
playbackSpeed?: number; playbackSpeed?: number;
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
// Technical info props
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
} }
export const HeaderControls: FC<HeaderControlsProps> = ({ export const HeaderControls: FC<HeaderControlsProps> = ({
@@ -52,6 +55,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
onZoomToggle, onZoomToggle,
playbackSpeed = 1.0, playbackSpeed = 1.0,
setPlaybackSpeed, setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
@@ -110,6 +115,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
<DropdownView <DropdownView
playbackSpeed={playbackSpeed} playbackSpeed={playbackSpeed}
setPlaybackSpeed={setPlaybackSpeed} setPlaybackSpeed={setPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={onToggleTechnicalInfo}
/> />
</View> </View>
)} )}

View 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,
},
});

View File

@@ -30,11 +30,15 @@ const SUBTITLE_SIZE_PRESETS = [
interface DropdownViewProps { interface DropdownViewProps {
playbackSpeed?: number; playbackSpeed?: number;
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
} }
const DropdownView = ({ const DropdownView = ({
playbackSpeed = 1.0, playbackSpeed = 1.0,
setPlaybackSpeed, setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
}: DropdownViewProps) => { }: DropdownViewProps) => {
const { subtitleTracks, audioTracks } = useVideoContext(); const { subtitleTracks, audioTracks } = useVideoContext();
const { item, mediaSource } = usePlayerContext(); 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; return groups;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
@@ -175,6 +194,8 @@ const DropdownView = ({
updateSettings, updateSettings,
playbackSpeed, playbackSpeed,
setPlaybackSpeed, setPlaybackSpeed,
showTechnicalInfo,
onToggleTechnicalInfo,
// Note: subtitleTracks and audioTracks are intentionally excluded // Note: subtitleTracks and audioTracks are intentionally excluded
// because we use subtitleTracksKey and audioTracksKey for stability // because we use subtitleTracksKey and audioTracksKey for stability
]); ]);

View File

@@ -430,6 +430,57 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.setPropertyDouble("panscan", panscanValue) 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 // MARK: - MPVLib.EventObserver
override fun eventProperty(property: String) { override fun eventProperty(property: String) {

View File

@@ -173,6 +173,11 @@ class MpvPlayerModule : Module() {
view.isZoomedToFill() view.isZoomedToFill()
} }
// Technical info function
AsyncFunction("getTechnicalInfo") { view: MpvPlayerView ->
view.getTechnicalInfo()
}
// Defines events that the view can send to JavaScript // Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady") Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
} }

View File

@@ -330,6 +330,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
return _isZoomedToFill return _isZoomedToFill
} }
// MARK: - Technical Info
fun getTechnicalInfo(): Map<String, Any> {
return renderer?.getTechnicalInfo() ?: emptyMap()
}
// MARK: - MPVLayerRenderer.Delegate // MARK: - MPVLayerRenderer.Delegate
override fun onPositionChanged(position: Double, duration: Double) { override fun onPositionChanged(position: Double, duration: Double) {

View File

@@ -762,4 +762,64 @@ final class MPVLayerRenderer {
getProperty(handle: handle, name: "aid", format: MPV_FORMAT_INT64, value: &aid) getProperty(handle: handle, name: "aid", format: MPV_FORMAT_INT64, value: &aid)
return Int(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
}
} }

View File

@@ -173,6 +173,11 @@ public class MpvPlayerModule: Module {
return view.isZoomedToFill() 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 // Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady") Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
} }

View File

@@ -282,6 +282,12 @@ class MpvPlayerView: ExpoView {
return _isZoomedToFill return _isZoomedToFill
} }
// MARK: - Technical Info
func getTechnicalInfo() -> [String: Any] {
return renderer?.getTechnicalInfo() ?? [:]
}
deinit { deinit {
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
renderer?.stop() renderer?.stop()

View File

@@ -89,6 +89,8 @@ export interface MpvPlayerViewRef {
// Video scaling // Video scaling
setZoomedToFill: (zoomed: boolean) => Promise<void>; setZoomedToFill: (zoomed: boolean) => Promise<void>;
isZoomedToFill: () => Promise<boolean>; isZoomedToFill: () => Promise<boolean>;
// Technical info
getTechnicalInfo: () => Promise<TechnicalInfo>;
} }
export type SubtitleTrack = { export type SubtitleTrack = {
@@ -106,3 +108,15 @@ export type AudioTrack = {
channels?: number; channels?: number;
selected?: boolean; selected?: boolean;
}; };
export type TechnicalInfo = {
videoWidth?: number;
videoHeight?: number;
videoCodec?: string;
audioCodec?: string;
fps?: number;
videoBitrate?: number;
audioBitrate?: number;
cacheSeconds?: number;
droppedFrames?: number;
};

View File

@@ -101,6 +101,10 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
isZoomedToFill: async () => { isZoomedToFill: async () => {
return await nativeRef.current?.isZoomedToFill(); return await nativeRef.current?.isZoomedToFill();
}, },
// Technical info
getTechnicalInfo: async () => {
return await nativeRef.current?.getTechnicalInfo();
},
})); }));
return <NativeView ref={nativeRef} {...props} />; return <NativeView ref={nativeRef} {...props} />;