diff --git a/bun.lock b/bun.lock index 94370c82..65caa248 100644 --- a/bun.lock +++ b/bun.lock @@ -49,7 +49,7 @@ }, "example": { "name": "react-native-video-example", - "version": "7.0.0-dev", + "version": "7.0.0-alpha.0", "dependencies": { "@react-native-community/slider": "^4.5.6", "react": "18.3.1", @@ -78,7 +78,7 @@ }, "packages/react-native-video": { "name": "react-native-video", - "version": "7.0.0-dev.13", + "version": "7.0.0-alpha.0", "devDependencies": { "@expo/config-plugins": "^10.0.2", "@react-native/eslint-config": "^0.77.0", diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 288ebf79..e2f51ea4 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1565,7 +1565,7 @@ PODS: - React-logger (= 0.77.2) - React-perflogger (= 0.77.2) - React-utils (= 0.77.2) - - ReactNativeVideo (7.0.0-dev.13): + - ReactNativeVideo (7.0.0-alpha.0): - DoubleConversion - glog - hermes-engine @@ -1876,7 +1876,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: f334cebc0beed0a72490492e978007082c03d533 ReactCodegen: 474fbb3e4bb0f1ee6c255d1955db76e13d509269 ReactCommon: 7763e59534d58e15f8f22121cdfe319040e08888 - ReactNativeVideo: 3e6350a16f84c9aea193b6821545436c2fc52864 + ReactNativeVideo: 8de1c56f95b9137b7c8f416982488a6d321a6542 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: 31a098f74c16780569aebd614a0f37a907de0189 diff --git a/example/src/App.tsx b/example/src/App.tsx index 36dff819..671682b5 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -21,6 +21,7 @@ import { type onProgressData, type ResizeMode, type TextTrack, + type VideoConfig, type VideoPlayerStatus, type VideoViewRef, } from 'react-native-video'; @@ -407,7 +408,21 @@ const VideoDemo = () => { onPress={() => { const newSource = { uri: 'https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/index.m3u8', - }; + externalSubtitles: [ + { + uri: 'https://bitdash-a.akamaihd.net/content/sintel/subtitles/subtitles_en.vtt', + label: 'External English', + language: 'en', + type: 'vtt', + }, + { + uri: 'https://bitdash-a.akamaihd.net/content/sintel/subtitles/subtitles_fr.vtt', + label: 'External French', + language: 'External French', + type: 'vtt', + }, + ], + } satisfies VideoConfig; player.replaceSourceAsync(newSource); }} /> diff --git a/packages/react-native-video/android/src/main/java/com/video/core/utils/TextTrackUtils.kt b/packages/react-native-video/android/src/main/java/com/video/core/utils/TextTrackUtils.kt index c48e2319..472b3fbf 100644 --- a/packages/react-native-video/android/src/main/java/com/video/core/utils/TextTrackUtils.kt +++ b/packages/react-native-video/android/src/main/java/com/video/core/utils/TextTrackUtils.kt @@ -13,23 +13,24 @@ object TextTrackUtils { return Threading.runOnMainThreadSync { val tracks = mutableListOf() val currentTracks = player.currentTracks + var globalTrackIndex = 0 // Get all text tracks from the current player tracks (includes both built-in and external) for (trackGroup in currentTracks.groups) { if (trackGroup.type == C.TRACK_TYPE_TEXT) { for (trackIndex in 0 until trackGroup.length) { val format = trackGroup.getTrackFormat(trackIndex) - val trackId = format.id ?: "text-$trackIndex" - val label = format.label ?: "Unknown ${trackIndex + 1}" + val trackId = format.id ?: "text-$globalTrackIndex" + val label = format.label ?: "Unknown ${globalTrackIndex + 1}" val language = format.language val isSelected = trackGroup.isTrackSelected(trackIndex) // Determine if this is an external track by checking if it matches external subtitle labels val isExternal = source.config.externalSubtitles?.any { subtitle -> label.contains(subtitle.label, ignoreCase = true) - } ?: false + } == true - val finalTrackId = if (isExternal) "external-$trackIndex" else trackId + val finalTrackId = if (isExternal) "external-$globalTrackIndex" else trackId tracks.add( TextTrack( @@ -39,6 +40,8 @@ object TextTrackUtils { selected = isSelected ) ) + + globalTrackIndex++ } } } @@ -75,14 +78,15 @@ object TextTrackUtils { val currentTracks = player.currentTracks var trackFound = false var selectedExternalTrackIndex: Int? = null + var globalTrackIndex = 0 // Find and select the specific text track for (trackGroup in currentTracks.groups) { if (trackGroup.type == C.TRACK_TYPE_TEXT) { for (trackIndex in 0 until trackGroup.length) { val format = trackGroup.getTrackFormat(trackIndex) - val currentTrackId = format.id ?: "text-$trackIndex" - val label = format.label ?: "Unknown ${trackIndex + 1}" + val currentTrackId = format.id ?: "text-$globalTrackIndex" + val label = format.label ?: "Unknown ${globalTrackIndex + 1}" // Check if this matches our target track (either by original ID or by external ID) val isExternal = source.config.externalSubtitles?.any { subtitle -> @@ -90,7 +94,7 @@ object TextTrackUtils { } == true val finalTrackId = - if (isExternal) "external-$trackIndex" else currentTrackId + if (isExternal) "external-$globalTrackIndex" else currentTrackId if (finalTrackId == textTrack.id) { // Enable this specific track @@ -104,7 +108,7 @@ object TextTrackUtils { // Update selection state selectedExternalTrackIndex = if (isExternal) { - trackIndex + globalTrackIndex } else { null } @@ -113,6 +117,8 @@ object TextTrackUtils { trackFound = true break } + + globalTrackIndex++ } if (trackFound) { break @@ -129,6 +135,7 @@ object TextTrackUtils { fun getSelectedTrack(player: ExoPlayer, source: HybridVideoPlayerSourceSpec): TextTrack? { return Threading.runOnMainThreadSync { val currentTracks = player.currentTracks + var globalTrackIndex = 0 // Find the currently selected text track for (trackGroup in currentTracks.groups) { @@ -136,8 +143,8 @@ object TextTrackUtils { for (trackIndex in 0 until trackGroup.length) { if (trackGroup.isTrackSelected(trackIndex)) { val format = trackGroup.getTrackFormat(trackIndex) - val trackId = format.id ?: "text-$trackIndex" - val label = format.label ?: "Unknown ${trackIndex + 1}" + val trackId = format.id ?: "text-$globalTrackIndex" + val label = format.label ?: "Unknown ${globalTrackIndex + 1}" val language = format.language // Determine if this is an external track by checking if it matches external subtitle labels @@ -145,7 +152,7 @@ object TextTrackUtils { label.contains(subtitle.label, ignoreCase = true) } == true - val finalTrackId = if (isExternal) "external-$trackIndex" else trackId + val finalTrackId = if (isExternal) "external-$globalTrackIndex" else trackId return@runOnMainThreadSync TextTrack( id = finalTrackId, @@ -154,7 +161,11 @@ object TextTrackUtils { selected = true ) } + globalTrackIndex++ } + } else if (trackGroup.type == C.TRACK_TYPE_TEXT) { + // Still need to increment global index for non-selected text track groups + globalTrackIndex += trackGroup.length } } diff --git a/packages/react-native-video/ios/core/Extensions/AVPlayerItem+externalSubtitles.swift b/packages/react-native-video/ios/core/Extensions/AVPlayerItem+externalSubtitles.swift index ec47ced6..dbd0ed4d 100644 --- a/packages/react-native-video/ios/core/Extensions/AVPlayerItem+externalSubtitles.swift +++ b/packages/react-native-video/ios/core/Extensions/AVPlayerItem+externalSubtitles.swift @@ -5,53 +5,31 @@ // Created by Krzysztof Moch on 08/05/2025. // -import Foundation import AVFoundation +import Foundation extension AVPlayerItem { - static func withExternalSubtitles(for asset: AVURLAsset, config: NativeVideoConfig) async throws -> AVPlayerItem { - let subtitlesAssets = config.externalSubtitles?.map { subtitle in - let url = URL(string: subtitle.uri) - return AVURLAsset(url: url!) + static func withExternalSubtitles(for asset: AVURLAsset, config: NativeVideoConfig) async throws + -> AVPlayerItem + { + if config.externalSubtitles?.isEmpty != false { + return AVPlayerItem(asset: asset) } - - do { - let mainVideoTracks = asset.tracks(withMediaType: .video) - let mainAudioTracks = asset.tracks(withMediaType: .audio) - let textTracks = subtitlesAssets?.flatMap { $0.tracks(withMediaType: .text) } ?? [] - - guard let videoTrack = mainVideoTracks.first(where: { $0.mediaType == .video }), - let audioTrack = mainAudioTracks.first(where: { $0.mediaType == .audio }) - else { - print("Could not find required tracks.") - // TODO: Create Error for this case - throw PlayerError.invalidSource.error() + + if asset.url.pathExtension == "m3u8" { + let supportedExternalSubtitles = config.externalSubtitles?.filter { subtitle in + ExternalSubtitlesUtils.isSubtitleTypeSupported(subtitle: subtitle) } - - let composition = AVMutableComposition() - - // Add video track - if let compositionVideoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) { - try compositionVideoTrack.insertTimeRange(CMTimeRange(start: .zero, duration: videoTrack.timeRange.duration), of: videoTrack, at: .zero) + + if supportedExternalSubtitles?.isEmpty == true { + return AVPlayerItem(asset: asset) + } else { + return try await ExternalSubtitlesUtils.modifyStreamManifestWithExternalSubtitles( + for: asset, config: config) } - - // Add audio track - if let compositionAudioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) { - try compositionAudioTrack.insertTimeRange(CMTimeRange(start: .zero, duration: audioTrack.timeRange.duration), of: audioTrack, at: .zero) - } - - for textTrack in textTracks { - // Add subtitle track - if let compositionTextTrack = composition.addMutableTrack(withMediaType: .text, preferredTrackID: kCMPersistentTrackID_Invalid) { - // We will trim the subtitle track to the duration of the video to match android behavior - try compositionTextTrack.insertTimeRange(CMTimeRange(start: .zero, duration: textTrack.timeRange.duration), of: videoTrack, at: .zero) - - compositionTextTrack.languageCode = textTrack.languageCode - compositionTextTrack.isEnabled = false // Disable by default - } - } - - return AVPlayerItem(asset: composition) } + + return try await ExternalSubtitlesUtils.createCompositionWithExternalSubtitles( + for: asset, config: config) } } diff --git a/packages/react-native-video/ios/core/Extensions/AVURLAsset+getAssetInformation.swift b/packages/react-native-video/ios/core/Extensions/AVURLAsset+getAssetInformation.swift index 0351d056..506df62d 100644 --- a/packages/react-native-video/ios/core/Extensions/AVURLAsset+getAssetInformation.swift +++ b/packages/react-native-video/ios/core/Extensions/AVURLAsset+getAssetInformation.swift @@ -13,9 +13,9 @@ extension AVURLAsset { isLive: false, orientation: .unknown ) - + videoInformation.fileSize = try await VideoFileHelper.getFileSize(for: url) - + // Check if asset is live stream if duration.flags.contains(.indefinite) { videoInformation.duration = -1 @@ -24,21 +24,45 @@ extension AVURLAsset { videoInformation.duration = Int64(CMTimeGetSeconds(duration)) videoInformation.isLive = false } - + if let videoTrack = 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 = videoTrack.orientation - + if #available(iOS 14.0, tvOS 14.0, visionOS 1.0, *) { videoInformation.isHDR = videoTrack.hasMediaCharacteristic(.containsHDRVideo) } + } else if url.pathExtension == "m3u8" { + // For HLS streams, we cannot get video track information directly + // So we download manifest and try to extract video information from it + + let manifestContent = try await HLSManifestParser.downloadManifest(from: url) + let manifestInfo = try HLSManifestParser.parseM3U8Manifest(manifestContent) + + if let videoStream = manifestInfo.streams.first { + videoInformation.width = Double(videoStream.width ?? Int(Double.nan)) + videoInformation.height = Double(videoStream.height ?? Int(Double.nan)) + videoInformation.bitrate = Double(videoStream.bandwidth ?? Int(Double.nan)) + } + + if videoInformation.width > 0 && videoInformation.height > 0 { + if videoInformation.width == videoInformation.height { + videoInformation.orientation = .square + } else if videoInformation.width > videoInformation.height { + videoInformation.orientation = .landscapeRight + } else if videoInformation.width < videoInformation.height { + videoInformation.orientation = .portrait + } else { + videoInformation.orientation = .unknown + } + } } - + return videoInformation } -} \ No newline at end of file +} diff --git a/packages/react-native-video/ios/core/HLSSubtitleInjector.swift b/packages/react-native-video/ios/core/HLSSubtitleInjector.swift new file mode 100644 index 00000000..5070b210 --- /dev/null +++ b/packages/react-native-video/ios/core/HLSSubtitleInjector.swift @@ -0,0 +1,367 @@ +import AVFoundation +import Foundation +import NitroModules + +class HLSSubtitleInjector: NSObject { + private let originalManifestUrl: URL + private let externalSubtitles: [NativeExternalSubtitle] + private var modifiedManifestContent: String? + private static let customScheme = "rnv-hls" + private static let subtitleScheme = "rnv-hls-subtitles" + private static let subtitleGroupID = "rnv-subs" + private static let resourceLoaderQueue = DispatchQueue( + label: "com.nitro.HLSSubtitleInjector.resourceLoaderQueue", + qos: .userInitiated + ) + + init(manifestUrl: URL, externalSubtitles: [NativeExternalSubtitle]) { + self.originalManifestUrl = manifestUrl + self.externalSubtitles = externalSubtitles + super.init() + } + + func createModifiedAsset() -> AVURLAsset { + let customURL = createCustomURL(from: originalManifestUrl) + let asset = AVURLAsset(url: customURL) + asset.resourceLoader.setDelegate( + self, + queue: Self.resourceLoaderQueue + ) + return asset + } + + private func createCustomURL(from originalURL: URL) -> URL { + var components = URLComponents( + url: originalURL, + resolvingAgainstBaseURL: false + )! + components.scheme = Self.customScheme + return components.url! + } + + private func getModifiedManifestContent() async throws -> String { + if let cached = modifiedManifestContent { + return cached + } + + let originalContent = try await HLSManifestParser.downloadManifest(from: originalManifestUrl) + let modifiedContent = try modifyM3U8Content( + originalContent, + with: externalSubtitles + ) + modifiedManifestContent = modifiedContent + return modifiedContent + } + + private func modifyM3U8Content( + _ originalContent: String, + with externalSubtitles: [NativeExternalSubtitle] + ) throws -> String { + let lines = originalContent.components(separatedBy: .newlines) + var modifiedLines: [String] = [] + var foundExtM3U = false + var isAfterVersionOrM3U = false + var hasSubtitleGroup = false + let baseURL = originalManifestUrl.deletingLastPathComponent() + + for line in lines { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + + if trimmedLine.hasPrefix("#EXTM3U") { + foundExtM3U = true + modifiedLines.append(line) + isAfterVersionOrM3U = true + continue + } + + if isAfterVersionOrM3U && !hasSubtitleGroup + && shouldInsertSubtitlesHere(line: trimmedLine) + { + for (index, subtitle) in externalSubtitles.enumerated() { + let subtitleTrack = createSubtitleTrackEntry(for: subtitle, index: index) + modifiedLines.append(subtitleTrack) + } + hasSubtitleGroup = true + isAfterVersionOrM3U = false + } + + let processedLine = HLSManifestParser.convertRelativeURLsToAbsolute( + line: line, + baseURL: baseURL + ) + + // Handle existing subtitle groups and stream info lines + if trimmedLine.hasPrefix("#EXT-X-MEDIA:") && trimmedLine.contains("TYPE=SUBTITLES") { + let modifiedMediaLine = replaceSubtitleGroupInMediaLine(processedLine) + modifiedLines.append(modifiedMediaLine) + } else if trimmedLine.hasPrefix("#EXT-X-STREAM-INF:") { + let modifiedStreamLine = replaceSubtitleGroupInStreamInf( + processedLine, hasSubtitleGroup: hasSubtitleGroup) + modifiedLines.append(modifiedStreamLine) + } else { + modifiedLines.append(processedLine) + } + } + + if foundExtM3U && !hasSubtitleGroup { + var finalLines: [String] = [] + var insertedSubtitles = false + + for line in modifiedLines { + finalLines.append(line) + + if !insertedSubtitles + && (line.hasPrefix("#EXTM3U") || line.hasPrefix("#EXT-X-VERSION")) + { + for (index, subtitle) in externalSubtitles.enumerated() { + let subtitleTrack = createSubtitleTrackEntry(for: subtitle, index: index) + finalLines.append(subtitleTrack) + } + insertedSubtitles = true + } + } + + modifiedLines = finalLines + } + + if !foundExtM3U { + throw SourceError.invalidUri(uri: originalManifestUrl.absoluteString) + .error() + } + + return modifiedLines.joined(separator: "\n") + } + + private func shouldInsertSubtitlesHere(line: String) -> Bool { + return line.hasPrefix("#EXT-X-STREAM-INF:") + || line.hasPrefix("#EXT-X-I-FRAME-STREAM-INF:") + || line.hasPrefix("#EXT-X-MEDIA:") + || line.hasPrefix("#EXTINF:") + || line.hasPrefix("#EXT-X-BYTERANGE:") + || (!line.hasPrefix("#") && !line.isEmpty + && !line.hasPrefix("#EXT-X-VERSION")) + } + + private func replaceSubtitleGroupInMediaLine(_ line: String) -> String { + // Find and replace GROUP-ID in subtitle media lines + let groupIdPattern = #"GROUP-ID="[^"]*""# + + if let regex = try? NSRegularExpression(pattern: groupIdPattern, options: []) { + let range = NSRange(location: 0, length: line.utf16.count) + let replacement = "GROUP-ID=\"\(Self.subtitleGroupID)\"" + return regex.stringByReplacingMatches( + in: line, options: [], range: range, withTemplate: replacement) + } + + return line + } + + private func replaceSubtitleGroupInStreamInf(_ line: String, hasSubtitleGroup: Bool) -> String { + // First, handle existing SUBTITLES= references + let subtitlesPattern = #"SUBTITLES="[^"]*""# + + var modifiedLine = line + if let regex = try? NSRegularExpression(pattern: subtitlesPattern, options: []) { + let range = NSRange(location: 0, length: line.utf16.count) + let replacement = "SUBTITLES=\"\(Self.subtitleGroupID)\"" + modifiedLine = regex.stringByReplacingMatches( + in: line, options: [], range: range, withTemplate: replacement) + } else if hasSubtitleGroup && !line.contains("SUBTITLES=") { + // Add subtitle group reference if we have subtitles but no existing reference + if line.hasSuffix(",") { + modifiedLine = line + "SUBTITLES=\"\(Self.subtitleGroupID)\"" + } else { + modifiedLine = line + ",SUBTITLES=\"\(Self.subtitleGroupID)\"" + } + } + + return modifiedLine + } + + private func createSubtitleTrackEntry(for subtitle: NativeExternalSubtitle, index: Int) + -> String + { + let subtitleM3U8URI = "\(Self.subtitleScheme)://\(index)/subtitle.m3u8" + + return + "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"\(Self.subtitleGroupID)\",NAME=\"\(subtitle.label)\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"\(subtitle.language)\",URI=\"\(subtitleM3U8URI)\"" + } +} + +// MARK: - AVAssetResourceLoaderDelegate + +extension HLSSubtitleInjector: AVAssetResourceLoaderDelegate { + func resourceLoader( + _ resourceLoader: AVAssetResourceLoader, + shouldWaitForLoadingOfRequestedResource loadingRequest: + AVAssetResourceLoadingRequest + ) -> Bool { + + guard let url = loadingRequest.request.url else { + return false + } + + switch url.scheme { + case Self.customScheme: + return handleMainManifest(url: url, loadingRequest: loadingRequest) + case Self.subtitleScheme: + return handleSubtitleM3U8(url: url, loadingRequest: loadingRequest) + default: + return false + } + } + + private func handleMainManifest(url: URL, loadingRequest: AVAssetResourceLoadingRequest) -> Bool { + guard url.path.hasSuffix(".m3u8") else { + return false + } + + Task { + do { + let modifiedContent = try await getModifiedManifestContent() + + guard let data = modifiedContent.data(using: .utf8) else { + throw SourceError.invalidUri(uri: "Failed to encode manifest content") + .error() + } + + if let contentRequest = loadingRequest.contentInformationRequest { + contentRequest.contentType = "application/x-mpegURL" + contentRequest.contentLength = Int64(data.count) + contentRequest.isByteRangeAccessSupported = true + } + + if let dataRequest = loadingRequest.dataRequest { + let requestedData: Data + + if dataRequest.requestedOffset > 0 || dataRequest.requestedLength > 0 { + let offset = Int(dataRequest.requestedOffset) + let length = + dataRequest.requestedLength > 0 + ? min(Int(dataRequest.requestedLength), data.count - offset) + : data.count - offset + + if offset < data.count && length > 0 { + requestedData = data.subdata(in: offset..<(offset + length)) + } else { + requestedData = Data() + } + } else { + requestedData = data + } + + dataRequest.respond(with: requestedData) + } + + loadingRequest.finishLoading() + } catch { + loadingRequest.finishLoading(with: error) + } + } + + return true + } + + private func handleSubtitleM3U8(url: URL, loadingRequest: AVAssetResourceLoadingRequest) -> Bool { + guard let indexString = url.host, let index = Int(indexString) else { + return false + } + + guard index < externalSubtitles.count else { + return false + } + + let subtitle = externalSubtitles[index] + + Task { + do { + guard let subtitleURL = URL(string: subtitle.uri) else { + throw SourceError.invalidUri(uri: "Invalid subtitle URI: \(subtitle.uri)").error() + } + + let (vttData, response) = try await URLSession.shared.data(from: subtitleURL) + + guard let httpResponse = response as? HTTPURLResponse, + 200...299 ~= httpResponse.statusCode + else { + throw SourceError.invalidUri(uri: "Subtitle request failed with status: \(response)") + .error() + } + + guard let vttString = String(data: vttData, encoding: .utf8) else { + throw SourceError.invalidUri(uri: "Failed to decode VTT content").error() + } + + let duration = extractDurationFromVTT(vttString) + + let m3u8Wrapper = """ + #EXTM3U + #EXT-X-VERSION:3 + #EXT-X-MEDIA-SEQUENCE:1 + #EXT-X-PLAYLIST-TYPE:VOD + #EXT-X-ALLOW-CACHE:NO + #EXT-X-TARGETDURATION:\(Int(duration)) + #EXTINF:\(String(format: "%.3f", duration)), no desc + \(subtitle.uri) + #EXT-X-ENDLIST + """ + + guard let m3u8Data = m3u8Wrapper.data(using: .utf8) else { + throw SourceError.invalidUri(uri: "Failed to create M3U8 wrapper").error() + } + + if let contentRequest = loadingRequest.contentInformationRequest { + contentRequest.contentType = "application/x-mpegURL" + contentRequest.contentLength = Int64(m3u8Data.count) + contentRequest.isByteRangeAccessSupported = true + } + + if let dataRequest = loadingRequest.dataRequest { + dataRequest.respond(with: m3u8Data) + } + + loadingRequest.finishLoading() + } catch { + loadingRequest.finishLoading(with: error) + } + } + + return true + } + + private func extractDurationFromVTT(_ vttString: String) -> Double { + // Extract duration from VTT timestamps (similar to the PR approach) + let timestampPattern = #"(?:(\d+):)?(\d+):([\d\.]+)"# + + guard let regex = try? NSRegularExpression(pattern: timestampPattern, options: []) else { + return 60.0 // Default fallback + } + + let matches = regex.matches( + in: vttString, + options: [], + range: NSRange(location: 0, length: vttString.utf16.count) + ) + + guard let lastMatch = matches.last, + let range = Range(lastMatch.range, in: vttString) + else { + return 60.0 // Default fallback + } + + let lastTimestampString = String(vttString[range]) + let components = lastTimestampString.components(separatedBy: ":").reversed() + .compactMap { Double($0) } + .enumerated() + .map { pow(60.0, Double($0.offset)) * $0.element } + .reduce(0, +) + + return max(components, 1.0) // Ensure at least 1 second + } + + func resourceLoader( + _ resourceLoader: AVAssetResourceLoader, + didCancel loadingRequest: AVAssetResourceLoadingRequest + ) { + } +} diff --git a/packages/react-native-video/ios/core/Utils/ExternalSubtitlesUtils.swift b/packages/react-native-video/ios/core/Utils/ExternalSubtitlesUtils.swift new file mode 100644 index 00000000..f8dd50e2 --- /dev/null +++ b/packages/react-native-video/ios/core/Utils/ExternalSubtitlesUtils.swift @@ -0,0 +1,124 @@ +import AVFoundation +import ObjectiveC + +private var HLSSubtitleInjectorAssociatedKey: UInt8 = 0 + +enum ExternalSubtitlesUtils { + static func isSubtitleTypeSupported(subtitle: NativeExternalSubtitle) -> Bool { + if subtitle.type == .vtt { + return true + } + + if let url = URL(string: subtitle.uri), url.pathExtension == "vtt" { + return true + } + + return false + } + + static func createCompositionWithExternalSubtitles( + for asset: AVURLAsset, + config: NativeVideoConfig + ) async throws -> AVPlayerItem { + let subtitlesAssets = try config.externalSubtitles?.map { subtitle in + guard let url = URL(string: subtitle.uri) else { + throw PlayerError.invalidTrackUrl(url: subtitle.uri).error() + } + + return AVURLAsset(url: url) + } + + do { + let mainVideoTracks = asset.tracks(withMediaType: .video) + let mainAudioTracks = asset.tracks(withMediaType: .audio) + let textTracks = + subtitlesAssets?.flatMap { $0.tracks(withMediaType: .text) } ?? [] + + guard + let videoTrack = mainVideoTracks.first(where: { $0.mediaType == .video } + ), + let audioTrack = mainAudioTracks.first(where: { $0.mediaType == .audio } + ) + else { + throw PlayerError.invalidSource.error() + } + + let composition = AVMutableComposition() + + if let compositionVideoTrack = composition.addMutableTrack( + withMediaType: .video, + preferredTrackID: kCMPersistentTrackID_Invalid + ) { + try compositionVideoTrack.insertTimeRange( + CMTimeRange(start: .zero, duration: videoTrack.timeRange.duration), + of: videoTrack, + at: .zero + ) + } + + if let compositionAudioTrack = composition.addMutableTrack( + withMediaType: .audio, + preferredTrackID: kCMPersistentTrackID_Invalid + ) { + try compositionAudioTrack.insertTimeRange( + CMTimeRange(start: .zero, duration: audioTrack.timeRange.duration), + of: audioTrack, + at: .zero + ) + } + + for textTrack in textTracks { + if let compositionTextTrack = composition.addMutableTrack( + withMediaType: .text, + preferredTrackID: kCMPersistentTrackID_Invalid + ) { + try compositionTextTrack.insertTimeRange( + CMTimeRange(start: .zero, duration: videoTrack.timeRange.duration), + of: textTrack, + at: .zero + ) + + compositionTextTrack.languageCode = textTrack.languageCode + compositionTextTrack.isEnabled = true + } + } + + return await AVPlayerItem(asset: composition) + } + } + + static func modifyStreamManifestWithExternalSubtitles( + for asset: AVURLAsset, + config: NativeVideoConfig + ) async throws -> AVPlayerItem { + guard let externalSubtitles = config.externalSubtitles, + !externalSubtitles.isEmpty + else { + return AVPlayerItem(asset: asset) + } + + let supportedSubtitles = externalSubtitles.filter { subtitle in + isSubtitleTypeSupported(subtitle: subtitle) + } + + guard !supportedSubtitles.isEmpty else { + return AVPlayerItem(asset: asset) + } + + let subtitleInjector = HLSSubtitleInjector( + manifestUrl: asset.url, + externalSubtitles: supportedSubtitles + ) + let modifiedAsset = subtitleInjector.createModifiedAsset() + let playerItem = AVPlayerItem(asset: modifiedAsset) + + objc_setAssociatedObject( + playerItem, + &HLSSubtitleInjectorAssociatedKey, + subtitleInjector, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + + return playerItem + } +} diff --git a/packages/react-native-video/ios/core/Utils/HLSManifestParser.swift b/packages/react-native-video/ios/core/Utils/HLSManifestParser.swift new file mode 100644 index 00000000..757fab51 --- /dev/null +++ b/packages/react-native-video/ios/core/Utils/HLSManifestParser.swift @@ -0,0 +1,170 @@ +import AVFoundation +import Foundation +import NitroModules + +class HLSManifestParser { + + /// Downloads manifest content from the given URL + static func downloadManifest(from url: URL) async throws -> String { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + 200...299 ~= httpResponse.statusCode + else { + throw SourceError.invalidUri(uri: url.absoluteString).error() + } + + guard let manifestContent = String(data: data, encoding: .utf8) else { + throw SourceError.invalidUri(uri: url.absoluteString).error() + } + + return manifestContent + } + + /// Converts relative URLs in a manifest line to absolute URLs + static func convertRelativeURLsToAbsolute(line: String, baseURL: URL) -> String { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + + if trimmedLine.isEmpty { + return line + } + + if trimmedLine.hasPrefix("#") { + if trimmedLine.contains("URI=") { + return convertURIParametersToAbsolute(line: line, baseURL: baseURL) + } + return line + } + + if !trimmedLine.hasPrefix("http://") && !trimmedLine.hasPrefix("https://") { + let absoluteURL = baseURL.appendingPathComponent(trimmedLine) + return absoluteURL.absoluteString + } + + return line + } + + /// Converts URI parameters in manifest lines to absolute URLs + static func convertURIParametersToAbsolute(line: String, baseURL: URL) -> String { + var modifiedLine = line + let uriPattern = #"URI="([^"]+)""# + + guard let regex = try? NSRegularExpression(pattern: uriPattern, options: []) + else { + return line + } + + let nsLine = line as NSString + let matches = regex.matches( + in: line, + options: [], + range: NSRange(location: 0, length: nsLine.length) + ) + + for match in matches.reversed() { + if match.numberOfRanges >= 2 { + let uriRange = match.range(at: 1) + let uri = nsLine.substring(with: uriRange) + + if !uri.hasPrefix("http://") && !uri.hasPrefix("https://") { + let absoluteURL = baseURL.appendingPathComponent(uri) + let fullRange = match.range(at: 0) + let replacement = "URI=\"\(absoluteURL.absoluteString)\"" + modifiedLine = (modifiedLine as NSString).replacingCharacters( + in: fullRange, + with: replacement + ) + } + } + } + + return modifiedLine + } + + /// Parses M3U8 manifest content and returns parsed information + static func parseM3U8Manifest(_ content: String) throws -> HLSManifestInfo { + let lines = content.components(separatedBy: .newlines) + var info = HLSManifestInfo() + + for line in lines { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + + if trimmedLine.hasPrefix("#EXTM3U") { + info.isValid = true + } + + // Parse version + if trimmedLine.hasPrefix("#EXT-X-VERSION:") { + let versionString = String(trimmedLine.dropFirst("#EXT-X-VERSION:".count)) + info.version = Int(versionString) + } + + // Parse stream info for resolution + if trimmedLine.hasPrefix("#EXT-X-STREAM-INF:") { + let streamInfo = parseStreamInf(trimmedLine) + info.streams.append(streamInfo) + } + } + + if !info.isValid { + throw SourceError.invalidUri(uri: "Invalid M3U8 format").error() + } + + return info + } + + /// Parses EXT-X-STREAM-INF line to extract stream information + private static func parseStreamInf(_ line: String) -> HLSStreamInfo { + var streamInfo = HLSStreamInfo() + + // Parse RESOLUTION + if let resolutionRange = line.range(of: "RESOLUTION=") { + let afterResolution = line[resolutionRange.upperBound...] + if let commaRange = afterResolution.range(of: ",") { + let resolutionValue = String(afterResolution[.. NitroModules.Promise { let promise = Promise() - + if status != .idle { promise.resolve(withResult: ()) return promise } - + Task.detached(priority: .userInitiated) { [weak self] in guard let self else { promise.reject(withError: LibraryError.deallocated(objectName: "HybridVideoPlayer").error()) return } - + do { let playerItem = try await self.initializePlayerItem() self.playerItem = playerItem - + self.playerQueue.sync { self.player = AVPlayer() self.player?.replaceCurrentItem(with: playerItem) @@ -230,62 +228,62 @@ class HybridVideoPlayer: HybridVideoPlayerSpec { promise.reject(withError: error) } } - + return promise } - + func play() throws { playerPointer.play() } - + func pause() throws { playerPointer.pause() } - + func seekBy(time: Double) throws { guard let currentItem = playerPointer.currentItem else { throw PlayerError.notInitialized.error() } - + let currentItemTime = currentItem.currentTime() - + // Duration is NaN for live streams let fixedDurration = duration.isNaN ? Double.infinity : duration - + // Clap by <0, duration> let newTime = max(0, min(currentItemTime.seconds + time, fixedDurration)) - + currentTime = newTime } - + func seekTo(time: Double) { currentTime = time } - + func replaceSourceAsync(source: (any HybridVideoPlayerSourceSpec)?) throws -> Promise { let promise = Promise() - + guard let source else { release() promise.resolve(withResult: ()) return promise } - + Task.detached(priority: .userInitiated) { [weak self] in guard let self else { promise.reject(withError: LibraryError.deallocated(objectName: "HybridVideoPlayer").error()) return } - + self.source = source self.playerItem = try await self.initializePlayerItem() - + playerQueue.sync { do { guard let player = self.player else { throw PlayerError.notInitialized.error() } - + player.replaceCurrentItem(with: self.playerItem) promise.resolve(withResult: ()) } catch { @@ -295,12 +293,12 @@ class HybridVideoPlayer: HybridVideoPlayerSpec { } } } - + return promise } - + // MARK: - Methods - + /** * Initialize the player item synchronously. This is used to initialize the player item before it is set to the player. * This is necessary because the player item is used to initialize the player. @@ -316,112 +314,120 @@ class HybridVideoPlayer: HybridVideoPlayerSpec { semaphore.signal() throw LibraryError.deallocated(objectName: "HybridVideoPlayer").error() } - + do { initializedItem = try await strongSelf.initializePlayerItem() } catch { initializationError = error } - + semaphore.signal() } - - semaphore.wait() // Block current thread (playerQueue) + + semaphore.wait() // Block current thread (playerQueue) if let error = initializationError, initializedItem == nil { throw error } - + return initializedItem! } - + private func initializePlayerItem() async throws -> AVPlayerItem { guard let _source = source as? HybridVideoPlayerSource else { status = .error throw PlayerError.invalidSource.error() } - - let isNetowrkSource = _source.url.isFileURL == false - eventEmitter.onLoadStart(.init(sourceType: isNetowrkSource ? .network : .local, source: _source)) - + + let isNetworkSource = _source.url.isFileURL == false + eventEmitter.onLoadStart( + .init(sourceType: isNetworkSource ? .network : .local, source: _source)) + try await _source.initializeAsset() - + guard let asset = _source.asset else { status = .error throw SourceError.failedToInitializeAsset.error() } - + let playerItem: AVPlayerItem - - // iOS does not support external subtitles for HLS streams - if let externalSubtiles = source.config.externalSubtitles, externalSubtiles.isEmpty == false, source.uri.hasSuffix(".m3u8") == false { + + if let externalSubtitles = source.config.externalSubtitles, externalSubtitles.isEmpty == false { playerItem = try await AVPlayerItem.withExternalSubtitles(for: asset, config: source.config) } else { playerItem = AVPlayerItem(asset: asset) } - + return playerItem } - + // MARK: - Text Track Management - + func getAvailableTextTracks() throws -> [TextTrack] { guard let currentItem = playerPointer.currentItem else { return [] } - + var tracks: [TextTrack] = [] - - // Get all text tracks from the media selection group (includes both built-in and external) - if let mediaSelection = currentItem.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) { + + if let mediaSelection = currentItem.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) + { for (index, option) in mediaSelection.options.enumerated() { - let isSelected = currentItem.currentMediaSelection.selectedMediaOption(in: mediaSelection) == option - - // Determine if this is an external track based on the display name or other characteristics - let isExternal = source.config.externalSubtitles?.contains { subtitle in - option.displayName.contains(subtitle.label) - } ?? false - - let trackId = isExternal ? "external-\(index)" : "builtin-\(option.displayName)-\(option.locale?.identifier ?? "unknown")" - - tracks.append(TextTrack( - id: trackId, - label: option.displayName, - language: option.locale?.identifier, - selected: isSelected - )) + let isSelected = + currentItem.currentMediaSelection.selectedMediaOption(in: mediaSelection) == option + + let name = + option.commonMetadata.first(where: { $0.commonKey == .commonKeyTitle })?.stringValue + ?? option.displayName + + let isExternal = + source.config.externalSubtitles?.contains { subtitle in + name.contains(subtitle.label) + } ?? false + + let trackId = + isExternal + ? "external-\(index)" + : "builtin-\(option.displayName)-\(option.locale?.identifier ?? "unknown")" + + tracks.append( + TextTrack( + id: trackId, + label: option.displayName, + language: option.locale?.identifier, + selected: isSelected + )) } } - + return tracks } - + func selectTextTrack(textTrack: TextTrack?) throws { guard let currentItem = playerPointer.currentItem else { throw PlayerError.notInitialized.error() } - - guard let mediaSelection = currentItem.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) else { + + guard + let mediaSelection = currentItem.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) + else { return } - - // If textTrack is nil, disable all text tracks + guard let textTrack = textTrack else { currentItem.select(nil, in: mediaSelection) selectedExternalTrackIndex = nil eventEmitter.onTrackChange(nil) return } - + if textTrack.id.isEmpty { - // Disable all text tracks currentItem.select(nil, in: mediaSelection) selectedExternalTrackIndex = nil eventEmitter.onTrackChange(nil) return } - - // Find and select the track by matching the ID + if textTrack.id.hasPrefix("external-") { let trackIndexStr = String(textTrack.id.dropFirst("external-".count)) if let trackIndex = Int(trackIndexStr), trackIndex < mediaSelection.options.count { @@ -431,7 +437,6 @@ class HybridVideoPlayer: HybridVideoPlayerSpec { eventEmitter.onTrackChange(textTrack) } } else if textTrack.id.hasPrefix("builtin-") { - // Handle built-in tracks for option in mediaSelection.options { let optionId = "builtin-\(option.displayName)-\(option.locale?.identifier ?? "unknown")" if optionId == textTrack.id { @@ -443,50 +448,54 @@ class HybridVideoPlayer: HybridVideoPlayerSpec { } } } - + var selectedTrack: TextTrack? { - get { - guard let currentItem = playerPointer.currentItem else { - return nil - } - - guard let mediaSelection = currentItem.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) else { - return nil - } - - guard let selectedOption = currentItem.currentMediaSelection.selectedMediaOption(in: mediaSelection) else { - return nil - } - - // Find the index of the selected option - guard let index = mediaSelection.options.firstIndex(of: selectedOption) else { - return nil - } - - // Determine if this is an external track based on the display name or other characteristics - let isExternal = source.config.externalSubtitles?.contains { subtitle in + guard let currentItem = playerPointer.currentItem else { + return nil + } + + guard + let mediaSelection = currentItem.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) + else { + return nil + } + + guard + let selectedOption = currentItem.currentMediaSelection.selectedMediaOption(in: mediaSelection) + else { + return nil + } + + guard let index = mediaSelection.options.firstIndex(of: selectedOption) else { + return nil + } + + let isExternal = + source.config.externalSubtitles?.contains { subtitle in selectedOption.displayName.contains(subtitle.label) } ?? false - - let trackId = isExternal ? "external-\(index)" : "builtin-\(selectedOption.displayName)-\(selectedOption.locale?.identifier ?? "unknown")" - - return TextTrack( - id: trackId, - label: selectedOption.displayName, - language: selectedOption.locale?.identifier, - selected: true - ) - } + + let trackId = + isExternal + ? "external-\(index)" + : "builtin-\(selectedOption.displayName)-\(selectedOption.locale?.identifier ?? "unknown")" + + return TextTrack( + id: trackId, + label: selectedOption.displayName, + language: selectedOption.locale?.identifier, + selected: true + ) } - + // MARK: - Memory Management - + var memorySize: Int { var size = 0 - + size += source.memorySize size += playerItem?.asset.estimatedMemoryUsage ?? 0 - + return size } } diff --git a/packages/react-native-video/nitrogen/generated/.gitattributes b/packages/react-native-video/nitrogen/generated/.gitattributes index aae64e23..fb7a0d5a 100644 --- a/packages/react-native-video/nitrogen/generated/.gitattributes +++ b/packages/react-native-video/nitrogen/generated/.gitattributes @@ -1 +1 @@ -* linguist-generated +** linguist-generated=true diff --git a/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerEventEmitterSpec.cpp b/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerEventEmitterSpec.cpp index 7ba7e923..b181c930 100644 --- a/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerEventEmitterSpec.cpp +++ b/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerEventEmitterSpec.cpp @@ -101,8 +101,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result]() -> void { - return __result->invoke(); + auto __resultRef = jni::make_global(__result); + return [__resultRef]() -> void { + return __resultRef->invoke(); }; } }(); @@ -119,8 +120,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](bool hasAudioFocus) -> void { - return __result->invoke(hasAudioFocus); + auto __resultRef = jni::make_global(__result); + return [__resultRef](bool hasAudioFocus) -> void { + return __resultRef->invoke(hasAudioFocus); }; } }(); @@ -137,8 +139,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](BandwidthData data) -> void { - return __result->invoke(data); + auto __resultRef = jni::make_global(__result); + return [__resultRef](BandwidthData data) -> void { + return __resultRef->invoke(data); }; } }(); @@ -155,8 +158,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](bool buffering) -> void { - return __result->invoke(buffering); + auto __resultRef = jni::make_global(__result); + return [__resultRef](bool buffering) -> void { + return __resultRef->invoke(buffering); }; } }(); @@ -173,8 +177,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](bool visible) -> void { - return __result->invoke(visible); + auto __resultRef = jni::make_global(__result); + return [__resultRef](bool visible) -> void { + return __resultRef->invoke(visible); }; } }(); @@ -191,8 +196,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result]() -> void { - return __result->invoke(); + auto __resultRef = jni::make_global(__result); + return [__resultRef]() -> void { + return __resultRef->invoke(); }; } }(); @@ -209,8 +215,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](bool externalPlaybackActive) -> void { - return __result->invoke(externalPlaybackActive); + auto __resultRef = jni::make_global(__result); + return [__resultRef](bool externalPlaybackActive) -> void { + return __resultRef->invoke(externalPlaybackActive); }; } }(); @@ -227,8 +234,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](onLoadData data) -> void { - return __result->invoke(data); + auto __resultRef = jni::make_global(__result); + return [__resultRef](onLoadData data) -> void { + return __resultRef->invoke(data); }; } }(); @@ -245,8 +253,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](onLoadStartData data) -> void { - return __result->invoke(data); + auto __resultRef = jni::make_global(__result); + return [__resultRef](onLoadStartData data) -> void { + return __resultRef->invoke(data); }; } }(); @@ -263,8 +272,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](onPlaybackStateChangeData data) -> void { - return __result->invoke(data); + auto __resultRef = jni::make_global(__result); + return [__resultRef](onPlaybackStateChangeData data) -> void { + return __resultRef->invoke(data); }; } }(); @@ -281,8 +291,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](double rate) -> void { - return __result->invoke(rate); + auto __resultRef = jni::make_global(__result); + return [__resultRef](double rate) -> void { + return __resultRef->invoke(rate); }; } }(); @@ -299,8 +310,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](onProgressData data) -> void { - return __result->invoke(data); + auto __resultRef = jni::make_global(__result); + return [__resultRef](onProgressData data) -> void { + return __resultRef->invoke(data); }; } }(); @@ -317,8 +329,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result]() -> void { - return __result->invoke(); + auto __resultRef = jni::make_global(__result); + return [__resultRef]() -> void { + return __resultRef->invoke(); }; } }(); @@ -335,8 +348,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](double seekTime) -> void { - return __result->invoke(seekTime); + auto __resultRef = jni::make_global(__result); + return [__resultRef](double seekTime) -> void { + return __resultRef->invoke(seekTime); }; } }(); @@ -353,8 +367,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](TimedMetadata metadata) -> void { - return __result->invoke(metadata); + auto __resultRef = jni::make_global(__result); + return [__resultRef](TimedMetadata metadata) -> void { + return __resultRef->invoke(metadata); }; } }(); @@ -371,8 +386,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](std::vector texts) -> void { - return __result->invoke(texts); + auto __resultRef = jni::make_global(__result); + return [__resultRef](std::vector texts) -> void { + return __resultRef->invoke(texts); }; } }(); @@ -389,8 +405,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](std::optional track) -> void { - return __result->invoke(track); + auto __resultRef = jni::make_global(__result); + return [__resultRef](std::optional track) -> void { + return __resultRef->invoke(track); }; } }(); @@ -407,8 +424,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](double volume) -> void { - return __result->invoke(volume); + auto __resultRef = jni::make_global(__result); + return [__resultRef](double volume) -> void { + return __resultRef->invoke(volume); }; } }(); @@ -425,8 +443,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](VideoPlayerStatus status) -> void { - return __result->invoke(status); + auto __resultRef = jni::make_global(__result); + return [__resultRef](VideoPlayerStatus status) -> void { + return __resultRef->invoke(status); }; } }(); diff --git a/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoViewViewManagerSpec.cpp b/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoViewViewManagerSpec.cpp index 51a58169..08c9bceb 100644 --- a/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoViewViewManagerSpec.cpp +++ b/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoViewViewManagerSpec.cpp @@ -94,8 +94,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](bool isInPictureInPicture) -> void { - return __result->invoke(isInPictureInPicture); + auto __resultRef = jni::make_global(__result); + return [__resultRef](bool isInPictureInPicture) -> void { + return __resultRef->invoke(isInPictureInPicture); }; } }()) : std::nullopt; @@ -112,8 +113,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result](bool fullscreen) -> void { - return __result->invoke(fullscreen); + auto __resultRef = jni::make_global(__result); + return [__resultRef](bool fullscreen) -> void { + return __resultRef->invoke(fullscreen); }; } }()) : std::nullopt; @@ -130,8 +132,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result]() -> void { - return __result->invoke(); + auto __resultRef = jni::make_global(__result); + return [__resultRef]() -> void { + return __resultRef->invoke(); }; } }()) : std::nullopt; @@ -148,8 +151,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result]() -> void { - return __result->invoke(); + auto __resultRef = jni::make_global(__result); + return [__resultRef]() -> void { + return __resultRef->invoke(); }; } }()) : std::nullopt; @@ -166,8 +170,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result]() -> void { - return __result->invoke(); + auto __resultRef = jni::make_global(__result); + return [__resultRef]() -> void { + return __resultRef->invoke(); }; } }()) : std::nullopt; @@ -184,8 +189,9 @@ namespace margelo::nitro::video { auto downcast = jni::static_ref_cast(__result); return downcast->cthis()->getFunction(); } else { - return [__result]() -> void { - return __result->invoke(); + auto __resultRef = jni::make_global(__result); + return [__resultRef]() -> void { + return __resultRef->invoke(); }; } }()) : std::nullopt; diff --git a/packages/react-native-video/nitrogen/generated/android/c++/JNativeExternalSubtitle.hpp b/packages/react-native-video/nitrogen/generated/android/c++/JNativeExternalSubtitle.hpp index 6da70c6e..6ecc6bdd 100644 --- a/packages/react-native-video/nitrogen/generated/android/c++/JNativeExternalSubtitle.hpp +++ b/packages/react-native-video/nitrogen/generated/android/c++/JNativeExternalSubtitle.hpp @@ -39,10 +39,13 @@ namespace margelo::nitro::video { jni::local_ref label = this->getFieldValue(fieldLabel); static const auto fieldType = clazz->getField("type"); jni::local_ref type = this->getFieldValue(fieldType); + static const auto fieldLanguage = clazz->getField("language"); + jni::local_ref language = this->getFieldValue(fieldLanguage); return NativeExternalSubtitle( uri->toStdString(), label->toStdString(), - type->toCpp() + type->toCpp(), + language->toStdString() ); } @@ -55,7 +58,8 @@ namespace margelo::nitro::video { return newInstance( jni::make_jstring(value.uri), jni::make_jstring(value.label), - JSubtitleType::fromCpp(value.type) + JSubtitleType::fromCpp(value.type), + jni::make_jstring(value.language) ); } }; diff --git a/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/NativeExternalSubtitle.kt b/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/NativeExternalSubtitle.kt index f226bee2..b40653c0 100644 --- a/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/NativeExternalSubtitle.kt +++ b/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/NativeExternalSubtitle.kt @@ -22,7 +22,8 @@ data class NativeExternalSubtitle constructor( val uri: String, val label: String, - val type: SubtitleType + val type: SubtitleType, + val language: String ) { /* main constructor */ } diff --git a/packages/react-native-video/nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Umbrella.hpp b/packages/react-native-video/nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Umbrella.hpp index f32e445f..30857f78 100644 --- a/packages/react-native-video/nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Umbrella.hpp +++ b/packages/react-native-video/nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Umbrella.hpp @@ -102,6 +102,7 @@ namespace margelo::nitro::video { struct onProgressData; } #include #include #include +#include // Forward declarations of Swift defined types // Forward declaration of `HybridVideoPlayerEventEmitterSpec_cxx` to properly resolve imports. diff --git a/packages/react-native-video/nitrogen/generated/ios/swift/NativeExternalSubtitle.swift b/packages/react-native-video/nitrogen/generated/ios/swift/NativeExternalSubtitle.swift index 8faad73c..472a8ddb 100644 --- a/packages/react-native-video/nitrogen/generated/ios/swift/NativeExternalSubtitle.swift +++ b/packages/react-native-video/nitrogen/generated/ios/swift/NativeExternalSubtitle.swift @@ -18,8 +18,8 @@ public extension NativeExternalSubtitle { /** * Create a new instance of `NativeExternalSubtitle`. */ - init(uri: String, label: String, type: SubtitleType) { - self.init(std.string(uri), std.string(label), type) + init(uri: String, label: String, type: SubtitleType, language: String) { + self.init(std.string(uri), std.string(label), type, std.string(language)) } var uri: String { @@ -54,4 +54,15 @@ public extension NativeExternalSubtitle { self.__type = newValue } } + + var language: String { + @inline(__always) + get { + return String(self.__language) + } + @inline(__always) + set { + self.__language = std.string(newValue) + } + } } diff --git a/packages/react-native-video/nitrogen/generated/shared/c++/NativeExternalSubtitle.hpp b/packages/react-native-video/nitrogen/generated/shared/c++/NativeExternalSubtitle.hpp index 16af6022..b02682df 100644 --- a/packages/react-native-video/nitrogen/generated/shared/c++/NativeExternalSubtitle.hpp +++ b/packages/react-native-video/nitrogen/generated/shared/c++/NativeExternalSubtitle.hpp @@ -34,10 +34,11 @@ namespace margelo::nitro::video { std::string uri SWIFT_PRIVATE; std::string label SWIFT_PRIVATE; SubtitleType type SWIFT_PRIVATE; + std::string language SWIFT_PRIVATE; public: NativeExternalSubtitle() = default; - explicit NativeExternalSubtitle(std::string uri, std::string label, SubtitleType type): uri(uri), label(label), type(type) {} + explicit NativeExternalSubtitle(std::string uri, std::string label, SubtitleType type, std::string language): uri(uri), label(label), type(type), language(language) {} }; } // namespace margelo::nitro::video @@ -54,7 +55,8 @@ namespace margelo::nitro { return NativeExternalSubtitle( JSIConverter::fromJSI(runtime, obj.getProperty(runtime, "uri")), JSIConverter::fromJSI(runtime, obj.getProperty(runtime, "label")), - JSIConverter::fromJSI(runtime, obj.getProperty(runtime, "type")) + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, "type")), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, "language")) ); } static inline jsi::Value toJSI(jsi::Runtime& runtime, const NativeExternalSubtitle& arg) { @@ -62,6 +64,7 @@ namespace margelo::nitro { obj.setProperty(runtime, "uri", JSIConverter::toJSI(runtime, arg.uri)); obj.setProperty(runtime, "label", JSIConverter::toJSI(runtime, arg.label)); obj.setProperty(runtime, "type", JSIConverter::toJSI(runtime, arg.type)); + obj.setProperty(runtime, "language", JSIConverter::toJSI(runtime, arg.language)); return obj; } static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { @@ -72,6 +75,7 @@ namespace margelo::nitro { if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, "uri"))) return false; if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, "label"))) return false; if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, "type"))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, "language"))) return false; return true; } }; diff --git a/packages/react-native-video/src/core/types/VideoConfig.ts b/packages/react-native-video/src/core/types/VideoConfig.ts index cf59a24a..b716a587 100644 --- a/packages/react-native-video/src/core/types/VideoConfig.ts +++ b/packages/react-native-video/src/core/types/VideoConfig.ts @@ -17,7 +17,25 @@ export type VideoConfig = { headers?: Record; /** * The external subtitles to be used. - * @note on iOS, side loaded subtitles are not supported if source is stream. + * @note on iOS, only WebVTT (.vtt) subtitles are supported (for HLS streams and MP4 files). + * @note on iOS, `label` can be overridden by player and there is no way to get around it. + * @example + * ```ts + * externalSubtitles: [ + * { + * uri: 'https://example.com/subtitles_en.vtt', + * label: 'English', + * type: 'vtt', + * language: 'en' + * }, + * { + * uri: 'https://example.com/subtitles_es.vtt', + * label: 'EspaƱol', + * type: 'vtt', + * language: 'es' + * } + * ] + * ``` */ externalSubtitles?: ExternalSubtitle[]; }; @@ -55,6 +73,12 @@ interface ExternalSubtitleWithInferredType { * The type of the subtitle. */ type?: SubtitleType; + /** + * The language code for the subtitle (ISO 639-1 or ISO 639-2). + * @example 'en', 'es', 'fr', 'de', 'zh-CN' + * @default 'und' (undefined) + */ + language?: string; } interface ExternalSubtitleWithCustomType { @@ -70,6 +94,12 @@ interface ExternalSubtitleWithCustomType { * The type of the subtitle. */ type: Omit; + /** + * The language code for the subtitle (ISO 639-1 or ISO 639-2). + * @example 'en', 'es', 'fr', 'de', 'zh-CN' + * @default 'und' (undefined) + */ + language?: string; } export type ExternalSubtitle = @@ -80,4 +110,5 @@ interface NativeExternalSubtitle { uri: string; label: string; type: SubtitleType; + language: string; } diff --git a/packages/react-native-video/src/core/utils/sourceFactory.ts b/packages/react-native-video/src/core/utils/sourceFactory.ts index 1debe73c..6c531dce 100644 --- a/packages/react-native-video/src/core/utils/sourceFactory.ts +++ b/packages/react-native-video/src/core/utils/sourceFactory.ts @@ -78,6 +78,7 @@ const parseExternalSubtitles = ( uri: subtitle.uri, label: subtitle.label, type: (subtitle.type ?? 'auto') as SubtitleType, + language: subtitle.language ?? 'und', })); };