diff --git a/CHANGELOG.md b/CHANGELOG.md index fdfe8c96..a78f0da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `Player.getAudioTrack` and `Player.getSubtitleTrack` APIs to get currently selected audio and subtitle tracks - `SourceConfig.description` property to allow setting a description for the source +- `Player.getThumbnail` and `Source.getThumbnail` APIs to get thumbnail images ## [0.10.0] (2023-09-04) 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 1a577b76..c3d4c3c1 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt @@ -452,6 +452,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 1a552350..c38f9ef4 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 @@ -937,5 +938,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 0041287c..1d70a2e9 100644 --- a/ios/PlayerModule.m +++ b/ios/PlayerModule.m @@ -92,5 +92,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 e3fec79a..a30f515b 100644 --- a/ios/PlayerModule.swift +++ b/ios/PlayerModule.swift @@ -564,4 +564,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 e9b2f900..bf4d08ba 100644 --- a/ios/RCTConvert+BitmovinPlayer.swift +++ b/ios/RCTConvert+BitmovinPlayer.swift @@ -935,4 +935,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 c76f3c5f..d2cea622 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; @@ -472,4 +473,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 15e341de..81dd86dc 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; @@ -222,4 +223,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; +}