diff --git a/CHANGELOG.md b/CHANGELOG.md index d83124fe..3e5ff589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- Support to get thumbnail images via `Player.getThumbnail` and `Source.getThumbnail` + ## [0.10.0] (2023-09-04) ### Added diff --git a/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt b/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt index c3801115..9023c3eb 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt @@ -428,6 +428,18 @@ class PlayerModule(private val context: ReactApplicationContext) : ReactContextB } } + /** + * Returns the thumbnail image for the active `Source` at a certain time. + * @param nativeId Target player id. + * @param time Playback time for the thumbnail. + */ + @ReactMethod + fun getThumbnail(nativeId: NativeId, time: Double, promise: Promise) { + uiManager()?.addUIBlock { + promise.resolve(JsonConverter.fromThumbnail(players[nativeId]?.source?.getThumbnail(time))) + } + } + /** * Helper function that returns the initialized `UIManager` instance. */ diff --git a/android/src/main/java/com/bitmovin/player/reactnative/SourceModule.kt b/android/src/main/java/com/bitmovin/player/reactnative/SourceModule.kt index fb56c8da..04d3a6f5 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/SourceModule.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/SourceModule.kt @@ -140,7 +140,6 @@ class SourceModule(private val context: ReactApplicationContext) : ReactContextB /** * Set the metadata for a loaded `nativeId` source. * @param nativeId Source `nativeId`. - * @param promise: JS promise object. */ @ReactMethod fun setMetadata(nativeId: NativeId, metadata: ReadableMap?) { @@ -149,6 +148,18 @@ class SourceModule(private val context: ReactApplicationContext) : ReactContextB } } + /** + * Returns the thumbnail image for the `Source` at a certain time. + * @param nativeId Target player id. + * @param time Playback time for the thumbnail. + */ + @ReactMethod + fun getThumbnail(nativeId: NativeId, time: Double, promise: Promise) { + uiManager()?.addUIBlock { + promise.resolve(JsonConverter.fromThumbnail(sources[nativeId]?.getThumbnail(time))) + } + } + /** * Helper method that converts a React `ReadableMap` into a kotlin String -> String map. */ diff --git a/android/src/main/java/com/bitmovin/player/reactnative/converter/JsonConverter.kt b/android/src/main/java/com/bitmovin/player/reactnative/converter/JsonConverter.kt index 931fad64..a1de5f05 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/converter/JsonConverter.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/converter/JsonConverter.kt @@ -24,6 +24,7 @@ import com.bitmovin.player.api.event.data.SeekPosition import com.bitmovin.player.api.media.AdaptationConfig import com.bitmovin.player.api.media.audio.AudioTrack import com.bitmovin.player.api.media.subtitle.SubtitleTrack +import com.bitmovin.player.api.media.thumbnail.Thumbnail import com.bitmovin.player.api.media.thumbnail.ThumbnailTrack import com.bitmovin.player.api.media.video.quality.VideoQuality import com.bitmovin.player.api.offline.options.OfflineContentOptions @@ -936,5 +937,23 @@ class JsonConverter { putArray("textOptions", options.textOptions.map { toJson(it) }.toReadableArray()) } } + + @JvmStatic + fun fromThumbnail(thumbnail: Thumbnail?): WritableMap? { + if (thumbnail == null) { + return null + } + + return Arguments.createMap().apply { + putDouble("start", thumbnail.start) + putDouble("end", thumbnail.end) + putString("text", thumbnail.text) + putString("url", thumbnail.uri.toString()) + putInt("x", thumbnail.x) + putInt("y", thumbnail.y) + putInt("width", thumbnail.width) + putInt("height", thumbnail.height) + } + } } } diff --git a/ios/PlayerModule.m b/ios/PlayerModule.m index d3d3ad02..1cd28bb8 100644 --- a/ios/PlayerModule.m +++ b/ios/PlayerModule.m @@ -84,5 +84,10 @@ @interface RCT_EXTERN_REMAP_MODULE(PlayerModule, PlayerModule, NSObject) resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(setMaxSelectableBitrate:(NSString *)nativeId maxSelectableBitrate:(nonnull NSNumber *)maxSelectableBitrate) +RCT_EXTERN_METHOD( + getThumbnail:(NSString *)nativeId + time:(nonnull NSNumber *)time + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) @end diff --git a/ios/PlayerModule.swift b/ios/PlayerModule.swift index 03da8a59..756aeae6 100644 --- a/ios/PlayerModule.swift +++ b/ios/PlayerModule.swift @@ -530,4 +530,22 @@ class PlayerModule: NSObject, RCTBridgeModule { self?.players[nativeId]?.maxSelectableBitrate = maxSelectableBitrateValue != -1 ? maxSelectableBitrateValue : 0 } } + + /** + Returns the thumbnail image for the active `Source` at a certain time. + - Parameter nativeId: Target player id. + - Parameter resolver: JS promise resolver. + - Parameter rejecter: JS promise rejecter. + */ + @objc(getThumbnail:time:resolver:rejecter:) + func getThumbnail( + _ nativeId: NativeId, + time: NSNumber, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + bridge.uiManager.addUIBlock { [weak self] _, _ in + resolve(RCTConvert.toJson(thumbnail: self?.players[nativeId]?.thumbnail(forTime: time.doubleValue))) + } + } } diff --git a/ios/RCTConvert+BitmovinPlayer.swift b/ios/RCTConvert+BitmovinPlayer.swift index 7147b8ef..72187bfd 100644 --- a/ios/RCTConvert+BitmovinPlayer.swift +++ b/ios/RCTConvert+BitmovinPlayer.swift @@ -932,4 +932,26 @@ extension RCTConvert { return adaptationConfig } + + /** + Utility method to compute a JS value from an `Thumbnail` object. + - Parameter thumbnail `Thumbnail` object to be converted. + - Returns: The produced JS object. + */ + static func toJson(thumbnail: Thumbnail?) -> [String: Any?]? { + guard let thumbnail = thumbnail else { + return nil + } + + return [ + "start": thumbnail.start, + "end": thumbnail.end, + "text": thumbnail.text, + "url": thumbnail.url.absoluteString, + "x": thumbnail.x, + "y": thumbnail.y, + "width": thumbnail.width, + "height": thumbnail.height, + ] + } } diff --git a/ios/SourceModule.m b/ios/SourceModule.m index eb29f76d..a876c77a 100644 --- a/ios/SourceModule.m +++ b/ios/SourceModule.m @@ -26,5 +26,10 @@ @interface RCT_EXTERN_REMAP_MODULE(SourceModule, SourceModule, NSObject) resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(setMetadata:(NSString *)nativeId metadata:(nullable id)metadata) +RCT_EXTERN_METHOD( + getThumbnail:(NSString *)nativeId + time:(nonnull NSNumber *)time + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) @end diff --git a/ios/SourceModule.swift b/ios/SourceModule.swift index 702500ec..2e4a1990 100644 --- a/ios/SourceModule.swift +++ b/ios/SourceModule.swift @@ -184,4 +184,22 @@ class SourceModule: NSObject, RCTBridgeModule { self?.sources[nativeId]?.metadata = metadata } } + + /** + Returns the thumbnail image for the `Source` at a certain time. + - Parameter nativeId: Target player id. + - Parameter resolver: JS promise resolver. + - Parameter rejecter: JS promise rejecter. + */ + @objc(getThumbnail:time:resolver:rejecter:) + func getThumbnail( + _ nativeId: NativeId, + time: NSNumber, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + bridge.uiManager.addUIBlock { [weak self] _, _ in + resolve(RCTConvert.toJson(thumbnail: self?.sources[nativeId]?.thumbnail(forTime: time.doubleValue))) + } + } } diff --git a/src/index.ts b/src/index.ts index 1683f570..9cbb0829 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,3 +12,4 @@ export * from './subtitleTrack'; export * from './styleConfig'; export * from './ui'; export * from './offline'; +export * from './thumbnail'; diff --git a/src/player.ts b/src/player.ts index 6cf0c624..22d99ad5 100644 --- a/src/player.ts +++ b/src/player.ts @@ -9,6 +9,7 @@ import { StyleConfig } from './styleConfig'; import { TweaksConfig } from './tweaksConfig'; import { AdaptationConfig } from './adaptationConfig'; import { OfflineContentManager, OfflineSourceOptions } from './offline'; +import { Thumbnail } from './thumbnail'; const PlayerModule = NativeModules.PlayerModule; @@ -458,4 +459,16 @@ export class Player extends NativeInstance { setMaxSelectableBitrate = (bitrate: number | null) => { PlayerModule.setMaxSelectableBitrate(this.nativeId, bitrate || -1); }; + + /** + * @returns a `Thumbnail` for the specified playback time for the currently active source if available. + * Supported thumbnail formats are: + * - `WebVtt` configured via `SourceConfig.thumbnailTrack`, on all supported platforms + * - HLS `Image Media Playlist` in the multivariant playlist, Android-only + * - DASH `Image Adaptation Set` as specified in DASH-IF IOP, Android-only + * If a `WebVtt` thumbnail track is provided, any potential in-manifest thumbnails are ignored on Android. + */ + getThumbnail = async (time: number): Promise => { + return PlayerModule.getThumbnail(this.nativeId, time); + }; } diff --git a/src/source.ts b/src/source.ts index 134bf9ad..95c9d1b0 100644 --- a/src/source.ts +++ b/src/source.ts @@ -2,6 +2,7 @@ import { NativeModules } from 'react-native'; import { Drm, DrmConfig } from './drm'; import NativeInstance, { NativeInstanceConfig } from './nativeInstance'; import { SideLoadedSubtitleTrack } from './subtitleTrack'; +import { Thumbnail } from './thumbnail'; const SourceModule = NativeModules.SourceModule; @@ -218,4 +219,16 @@ export class Source extends NativeInstance { loadingState = async (): Promise => { return SourceModule.loadingState(this.nativeId); }; + + /** + * @returns a `Thumbnail` for the specified playback time if available. + * Supported thumbnail formats are: + * - `WebVtt` configured via `SourceConfig.thumbnailTrack`, on all supported platforms + * - HLS `Image Media Playlist` in the multivariant playlist, Android-only + * - DASH `Image Adaptation Set` as specified in DASH-IF IOP, Android-only + * If a `WebVtt` thumbnail track is provided, any potential in-manifest thumbnails are ignored on Android. + */ + getThumbnail = async (time: number): Promise => { + return SourceModule.getThumbnail(this.nativeId, time); + }; } diff --git a/src/thumbnail.ts b/src/thumbnail.ts new file mode 100644 index 00000000..9e9b50b2 --- /dev/null +++ b/src/thumbnail.ts @@ -0,0 +1,37 @@ +/** + * Represents a VTT thumbnail. + */ +export interface Thumbnail { + /** + * The start time of the thumbnail. + */ + start: number; + /** + * The end time of the thumbnail. + */ + end: number; + /** + * The raw cue data. + */ + text: string; + /** + * The URL of the spritesheet + */ + url: string; + /** + * The horizontal offset of the thumbnail in its spritesheet + */ + x: number; + /** + * The vertical offset of the thumbnail in its spritesheet + */ + y: number; + /** + * The width of the thumbnail + */ + width: number; + /** + * The height of the thumbnail + */ + height: number; +}