diff --git a/NitroVideo.podspec b/NitroVideo.podspec index f028be18..0493a100 100644 --- a/NitroVideo.podspec +++ b/NitroVideo.podspec @@ -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 ] diff --git a/android/src/main/java/com/video/hybrids/HybridVideoPlayerSource.kt b/android/src/main/java/com/video/hybrids/HybridVideoPlayerSource.kt index 52f9816a..845aed4a 100644 --- a/android/src/main/java/com/video/hybrids/HybridVideoPlayerSource.kt +++ b/android/src/main/java/com/video/hybrids/HybridVideoPlayerSource.kt @@ -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 { + return Promise.async { + return@async AssetUtils.getAssetInformation(uri) + } + } + override val memorySize: Long get() = 0 } diff --git a/android/src/main/java/com/video/utils/AssetUtils.kt b/android/src/main/java/com/video/utils/AssetUtils.kt new file mode 100644 index 00000000..8effdf2f --- /dev/null +++ b/android/src/main/java/com/video/utils/AssetUtils.kt @@ -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()) + } + } + + // 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 + } + } + } +} diff --git a/ios/core/AVAssetUtils.swift b/ios/core/AVAssetUtils.swift new file mode 100644 index 00000000..129acd9b --- /dev/null +++ b/ios/core/AVAssetUtils.swift @@ -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 + } + } +} diff --git a/ios/core/VideoQueues.swift b/ios/core/VideoQueues.swift new file mode 100644 index 00000000..1dde8df2 --- /dev/null +++ b/ios/core/VideoQueues.swift @@ -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) +} diff --git a/ios/hybrids/HybridVideoPlayerSource.swift b/ios/hybrids/HybridVideoPlayerSource.swift index 9606119a..d08668dc 100644 --- a/ios/hybrids/HybridVideoPlayerSource.swift +++ b/ios/hybrids/HybridVideoPlayerSource.swift @@ -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 { + 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?") + } } } diff --git a/src/spec/nitro/VideoPlayerSource.nitro.ts b/src/spec/nitro/VideoPlayerSource.nitro.ts index b87dffc9..42d19dae 100644 --- a/src/spec/nitro/VideoPlayerSource.nitro.ts +++ b/src/spec/nitro/VideoPlayerSource.nitro.ts @@ -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; } export interface VideoPlayerSourceFactory diff --git a/src/types/VideoInformation.ts b/src/types/VideoInformation.ts new file mode 100644 index 00000000..4859b122 --- /dev/null +++ b/src/types/VideoInformation.ts @@ -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; +} \ No newline at end of file diff --git a/src/utils/useVideoPlayer.ts b/src/utils/useVideoPlayer.ts index b9eb46a8..bd56c49c 100644 --- a/src/utils/useVideoPlayer.ts +++ b/src/utils/useVideoPlayer.ts @@ -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( () => {