diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index d51b96a4..3a07fef0 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -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(() => { + 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} /> )} diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index b33d2b2c..24fd135c 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -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 }> = ({ {option.label} {isToggle ? ( - ) : option.selected ? ( + ) : isAction ? null : (option as RadioOption).selected ? ( ) : ( @@ -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( + , + ); + }); + return items; })} diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 997b17ed..96dfad6b 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -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; + playMethod?: "DirectPlay" | "DirectStream" | "Transcode"; + transcodeReasons?: string[]; } export const Controls: FC = ({ @@ -88,6 +96,11 @@ export const Controls: FC = ({ 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 = ({ onSkipForward={handleSkipForward} onSkipBackward={handleSkipBackward} /> + {/* Technical Info Overlay - rendered outside animated views to stay visible */} + {getTechnicalInfo && ( + + )} = ({ onZoomToggle={onZoomToggle} playbackSpeed={playbackSpeed} setPlaybackSpeed={setPlaybackSpeed} + showTechnicalInfo={showTechnicalInfo} + onToggleTechnicalInfo={onToggleTechnicalInfo} /> void; + // Technical info props + showTechnicalInfo?: boolean; + onToggleTechnicalInfo?: () => void; } export const HeaderControls: FC = ({ @@ -52,6 +55,8 @@ export const HeaderControls: FC = ({ onZoomToggle, playbackSpeed = 1.0, setPlaybackSpeed, + showTechnicalInfo = false, + onToggleTechnicalInfo, }) => { const { settings } = useSettings(); const router = useRouter(); @@ -110,6 +115,8 @@ export const HeaderControls: FC = ({ )} diff --git a/components/video-player/controls/TechnicalInfoOverlay.tsx b/components/video-player/controls/TechnicalInfoOverlay.tsx new file mode 100644 index 00000000..1498990d --- /dev/null +++ b/components/video-player/controls/TechnicalInfoOverlay.tsx @@ -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; + 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 = { + 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 = { + 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 = memo( + ({ + showControls, + visible, + getTechnicalInfo, + playMethod, + transcodeReasons, + }) => { + const { settings } = useSettings(); + const insets = useSafeAreaInsets(); + const [info, setInfo] = useState(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 ( + + + {playMethod && ( + + {getPlayMethodLabel(playMethod)} + + )} + {transcodeReasons && transcodeReasons.length > 0 && ( + + {transcodeReasons.map(formatTranscodeReason).join(", ")} + + )} + {info?.videoWidth && info?.videoHeight && ( + + {info.videoWidth}x{info.videoHeight} + + )} + {info?.videoCodec && ( + + Video: {formatCodec(info.videoCodec)} + {info.fps ? ` @ ${formatFps(info.fps)} fps` : ""} + + )} + {info?.audioCodec && ( + + Audio: {formatCodec(info.audioCodec)} + + )} + {(info?.videoBitrate || info?.audioBitrate) && ( + + Bitrate:{" "} + {info.videoBitrate + ? formatBitrate(info.videoBitrate) + : info.audioBitrate + ? formatBitrate(info.audioBitrate) + : "N/A"} + + )} + {info?.cacheSeconds !== undefined && ( + + Buffer: {info.cacheSeconds.toFixed(1)}s + + )} + {info?.droppedFrames !== undefined && info.droppedFrames > 0 && ( + + Dropped: {info.droppedFrames} frames + + )} + {!info && !playMethod && ( + Loading... + )} + + + ); + }, +); + +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, + }, +}); diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index 83487d29..5b631ec4 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -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 ]); diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt index 039ff94a..68db2e6d 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -430,6 +430,57 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { MPVLib.setPropertyDouble("panscan", panscanValue) } + // MARK: - Technical Info + + fun getTechnicalInfo(): Map { + val info = mutableMapOf() + + // 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) { diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt index 053082e1..245c4ef5 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt @@ -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") } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt index ecc7ab52..b746b74c 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt @@ -330,6 +330,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context return _isZoomedToFill } + // MARK: - Technical Info + + fun getTechnicalInfo(): Map { + return renderer?.getTechnicalInfo() ?: emptyMap() + } + // MARK: - MPVLayerRenderer.Delegate override fun onPositionChanged(position: Double, duration: Double) { diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index 728e9fc3..6ff71ae9 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -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 + } } diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift index c8355791..b60a3d40 100644 --- a/modules/mpv-player/ios/MpvPlayerModule.swift +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -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") } diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index b4cc40bf..608448b8 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -282,6 +282,12 @@ class MpvPlayerView: ExpoView { return _isZoomedToFill } + // MARK: - Technical Info + + func getTechnicalInfo() -> [String: Any] { + return renderer?.getTechnicalInfo() ?? [:] + } + deinit { pipController?.stopPictureInPicture() renderer?.stop() diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index 8ed61d51..dc25007b 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -89,6 +89,8 @@ export interface MpvPlayerViewRef { // Video scaling setZoomedToFill: (zoomed: boolean) => Promise; isZoomedToFill: () => Promise; + // Technical info + getTechnicalInfo: () => Promise; } 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; +}; diff --git a/modules/mpv-player/src/MpvPlayerView.tsx b/modules/mpv-player/src/MpvPlayerView.tsx index e5e0ccda..ad3fcdfa 100644 --- a/modules/mpv-player/src/MpvPlayerView.tsx +++ b/modules/mpv-player/src/MpvPlayerView.tsx @@ -101,6 +101,10 @@ export default React.forwardRef( isZoomedToFill: async () => { return await nativeRef.current?.isZoomedToFill(); }, + // Technical info + getTechnicalInfo: async () => { + return await nativeRef.current?.getTechnicalInfo(); + }, })); return ;