feat: add getAssetInformationAsync function to source object

This commit is contained in:
Krzysztof Moch
2025-01-18 23:55:03 +01:00
parent 1f89daa460
commit cefee73076
9 changed files with 344 additions and 19 deletions
+2 -1
View File
@@ -18,7 +18,8 @@ Pod::Spec.new do |s|
s.source_files = [
"ios/*.{h,m,mm,swift}",
"ios/hybrids/*.{h,m,mm,swift}", # Nitro Hybrid files
"ios/core/**/*.{h,m,mm,swift}", # Core library files
"ios/hybrids/**/*.{h,m,mm,swift}", # Nitro Hybrid files
"ios/view/**/*.{h,m,mm,swift}" # Video View files
]
@@ -2,10 +2,13 @@ package com.margelo.nitro.video
import androidx.media3.common.MediaItem
import com.facebook.proguard.annotations.DoNotStrip
import com.margelo.nitro.core.Promise
import com.video.utils.AssetUtils
@DoNotStrip
class HybridVideoPlayerSource(): HybridVideoPlayerSourceSpec() {
override lateinit var uri: String
lateinit var mediaItem: MediaItem
constructor(uri: String) : this() {
@@ -13,6 +16,12 @@ class HybridVideoPlayerSource(): HybridVideoPlayerSourceSpec() {
this.mediaItem = MediaItem.fromUri(uri)
}
override fun getAssetInformationAsync(): Promise<VideoInformation> {
return Promise.async {
return@async AssetUtils.getAssetInformation(uri)
}
}
override val memorySize: Long
get() = 0
}
@@ -0,0 +1,115 @@
package com.video.utils
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.webkit.URLUtil
import com.margelo.nitro.video.VideoInformation
import com.margelo.nitro.video.VideoOrientation
import java.io.File
import java.net.URL
import java.net.URLConnection
class AssetUtils {
companion object {
fun getAssetInformation(uri: String): VideoInformation {
val retriever = MediaMetadataRetriever()
when {
URLUtil.isFileUrl(uri) -> {
retriever.setDataSource(Uri.parse(uri).path)
}
else -> {
//TODO: pass headers here
retriever.setDataSource(uri, HashMap<String, String>())
}
}
// Get dimensions
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toDoubleOrNull() ?: Double.NaN
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toDoubleOrNull() ?: Double.NaN
// Get duration in milliseconds, convert to long
val duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: -1L
// If we have some valid info, but there is no duration it might be live
val isLive = !width.isNaN() && !height.isNaN() && duration <= 0
// Get bitrate
val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)?.toDoubleOrNull() ?: Double.NaN
// Get rotation
val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
// Check for HDR by looking at color transfer (API 30+)
val isHDR = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val colorTransfer = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER)?.toIntOrNull()
colorTransfer == MediaFormat.COLOR_TRANSFER_ST2084 || colorTransfer == MediaFormat.COLOR_TRANSFER_HLG
} else {
false
}
// Clean up
retriever.release()
// Get file size
val fileSize = getFileSizeFromUri(uri)
val videoInfo = VideoInformation(
bitrate = bitrate,
width = width,
height = height,
duration = duration,
fileSize = fileSize,
isHDR = isHDR,
isLive = isLive,
orientation = getVideoOrientation(width.toInt(), height.toInt(), rotation)
)
return videoInfo
}
fun getFileSizeFromUri(uri: String): Long {
return try {
when {
URLUtil.isFileUrl(uri) -> {
val file = File(Uri.parse(uri).path ?: return -1)
if (file.exists()) file.length() else -1
}
URLUtil.isNetworkUrl(uri) -> {
val connection: URLConnection = URL(uri).openConnection()
connection.connect()
connection.contentLength.toLong()
}
else -> -1
}
} catch (e: Exception) {
-1
}
}
fun getVideoOrientation(width: Int?, height: Int?, rotation: Int?): VideoOrientation {
if (width == 0 || height == 0 || height == null || width == null) return VideoOrientation.UNKNOWN
// Check if video is portrait or landscape using natural size
val isNaturalSizePortrait = height > width
// If rotation is not available, use natural size to determine orientation
if (rotation == null) {
return if (isNaturalSizePortrait) VideoOrientation.PORTRAIT else VideoOrientation.LANDSCAPE_RIGHT
}
// Normalize rotation to 0-360 range
val normalizedRotation = ((rotation % 360) + 360) % 360
return when (normalizedRotation) {
0 -> if (isNaturalSizePortrait) VideoOrientation.PORTRAIT else VideoOrientation.LANDSCAPE_RIGHT
90 -> VideoOrientation.PORTRAIT
180 -> if (isNaturalSizePortrait) VideoOrientation.PORTRAIT_UPSIDE_DOWN else VideoOrientation.LANDSCAPE_LEFT
270 -> VideoOrientation.PORTRAIT_UPSIDE_DOWN
else -> if (isNaturalSizePortrait) VideoOrientation.PORTRAIT else VideoOrientation.LANDSCAPE_RIGHT
}
}
}
}
+97
View File
@@ -0,0 +1,97 @@
//
// AVAssetUtils.swift
// Pods
//
// Created by Krzysztof Moch on 16/01/2025.
//
import AVFoundation
public final class AVAssetUtils {
public static func getAssetInformation(for asset: AVAsset) async throws -> VideoInformation {
// Initialize with default values
var videoInformation = VideoInformation(
bitrate: Double.nan,
width: Double.nan,
height: Double.nan,
duration: -1,
fileSize: -1,
isHDR: false,
isLive: false,
orientation: .unknown
)
videoInformation.fileSize = try await getFileSize(for: <#T##URL#>)
// Check if asset is live stream
if asset.duration.flags.contains(.indefinite) {
videoInformation.duration = -1
videoInformation.isLive = true
} else {
videoInformation.duration = Int64(CMTimeGetSeconds(asset.duration))
videoInformation.isLive = false
}
if let videoTrack = asset.tracks(withMediaType: .video).first {
let size = videoTrack.naturalSize.applying(videoTrack.preferredTransform)
videoInformation.width = size.width
videoInformation.height = size.height
videoInformation.bitrate = Double(videoTrack.estimatedDataRate)
videoInformation.orientation = getVideoOrientation(videoTrack: videoTrack)
if #available(iOS 14.0, tvOS 14.0, visionOS 1.0, *) {
videoInformation.isHDR = videoTrack.hasMediaCharacteristic(.containsHDRVideo)
}
}
return videoInformation
}
public static func getFileSize(for url: URL) async throws -> Int64 {
if url.isFileURL {
return try Int64(url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? -1)
}
// Try to get file size from remote server
// Make HEAD request to get content length
// If content length is not available, returns -1
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
return -1
}
let contentLength = httpResponse.allHeaderFields["Content-Length"] as? String
return Int64(contentLength ?? "-1") ?? -1
}
public static func getVideoOrientation(videoTrack: AVAssetTrack) -> VideoOrientation {
let transform = videoTrack.preferredTransform
let size = videoTrack.naturalSize.applying(transform)
// Check if video is portrait or landscape
let isNaturalSizePortrait = size.width < size.height
// Calculate video rotation
let angle = atan2(Double(transform.b), Double(transform.a))
let degrees = angle * 180 / .pi
let rotation = degrees < 0 ? degrees + 360 : degrees
switch rotation {
case 0:
return isNaturalSizePortrait ? .portrait : .landscapeRight
case 90, -270:
return .portrait
case 180, -180:
return isNaturalSizePortrait ? .portraitUpsideDown : .landscapeLeft
case 270, -90:
return .portraitUpsideDown
default:
return isNaturalSizePortrait ? .portrait : .landscape
}
}
}
+15
View File
@@ -0,0 +1,15 @@
//
// VideoQueues.swift
// Pods
//
// Created by Krzysztof Moch on 16/01/2025.
//
import Foundation
public final class VideoQueues {
public static let videoAssetQueue = DispatchQueue(label: "RNVideo/videoAssetQueue",
qos: .utility,
attributes: [],
autoreleaseFrequency: .inherit)
}
+44 -14
View File
@@ -7,27 +7,57 @@
import Foundation
import AVFoundation
import NitroModules
class HybridVideoPlayerSource: HybridVideoPlayerSourceSpec {
var uri: String {
didSet {
guard let url = URL(string: uri) else {
return
public var asset: AVURLAsset?
var uri: String
let url: URL
init(uri: String) throws {
self.uri = uri
guard let url = URL(string: uri) else {
throw RuntimeError.error(withMessage: "Invalid URL: \(uri)")
}
self.url = url
}
func getAssetInformationAsync() throws -> Promise<VideoInformation> {
return Promise.async(.utility) { [weak self] in
guard let self else {
throw RuntimeError.error(withMessage: "HybridVideoPlayerSource has been deallocated")
}
playerItem = AVPlayerItem(url: url)
if self.url.isFileURL {
try checkReadFilePermission(for: self.url)
}
try initializeAsset()
guard let asset = self.asset else {
throw RuntimeError.error(withMessage: "Failed to initialize asset")
}
return try await AVAssetUtils.getAssetInformation(for: asset)
}
}
var playerItem: AVPlayerItem?
init(uri: String) {
self.uri = uri
public func initializeAsset() throws {
guard asset == nil else {
return
}
// TODO: Pass headers here
asset = AVURLAsset(url: url)
}
// Initialize HybridContext
var hybridContext = margelo.nitro.HybridContext()
// Return size of the instance to inform JS GC about memory pressure
var memorySize: Int {
return getSizeOf(self)
private func checkReadFilePermission(for path: URL) throws {
let fileManager = FileManager.default
if !fileManager.isReadableFile(atPath: path.path) {
throw RuntimeError.error(withMessage: "Cannot read file at path: \(path.path), is path \(path.path) correct? Does app have permission to read file?")
}
}
}
+15 -1
View File
@@ -1,8 +1,22 @@
import type { HybridObject } from 'react-native-nitro-modules';
import type { VideoInformation } from '../../types/VideoInformation';
/**
* A source for a {@link VideoPlayer}.
* Source cannot be changed after it is created. If you need to update the source, you need to create a new one.
* It provides functions to get information about the asset.
*/
export interface VideoPlayerSource
extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> {
uri: string;
/**
* The URI of the asset.
*/
readonly uri: string;
/**
* Get the information about the asset.
*/
getAssetInformationAsync(): Promise<VideoInformation>;
}
export interface VideoPlayerSourceFactory
+44
View File
@@ -0,0 +1,44 @@
export type VideoOrientation = "portrait" | "landscape" | "portrait-upside-down" | "landscape-left" | "landscape-right" | "unknown";
export interface VideoInformation {
/**
* The bitrate of the video in kbps.
*/
bitrate: number;
/**
* The width of the video in pixels.
*/
width: number;
/**
* The height of the video in pixels.
*/
height: number;
/**
* The duration of the video in seconds.
*/
duration: bigint;
/**
* The file size of the video in bytes.
*/
fileSize: bigint;
/**
* Whether the video is HDR.
*/
isHDR: boolean;
/**
* Whether the video is live
*/
isLive: boolean;
/**
* The orientation of the video.
* see {@link VideoOrientation}
*/
orientation: VideoOrientation;
}
+3 -3
View File
@@ -1,7 +1,7 @@
import type { VideoPlayer } from "../spec/nitro/VideoPlayer.nitro";
import { useReleasingHybridObject } from "./useReleasingHybridObject";
import { createPlayer } from "./factory";
import type { VideoSource } from "../types/VideoConfig";
import type { VideoConfig, VideoSource } from "../types/VideoConfig";
/**
* A hook that creates and manages a video player.
@@ -11,8 +11,8 @@ import type { VideoSource } from "../types/VideoConfig";
* @returns VideoPlayer (see {@link VideoPlayer})
*/
export const useVideoPlayer = (
source: VideoSource,
setup: (player: VideoPlayer) => void | undefined
source: VideoConfig | VideoSource,
setup?: (player: VideoPlayer) => void
) => {
return useReleasingHybridObject(
() => {