diff --git a/android/build.gradle b/android/build.gradle index 73230734..a0b81e55 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -92,4 +92,5 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "com.amazonaws:ivs-player:$ivs_version" implementation("com.squareup.okhttp3:okhttp:5.2.1") + implementation "androidx.media:media:1.6.0" } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index f8eb3953..ac827a74 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + package="com.amazonaws.ivs.reactnative.player"> + + + diff --git a/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsView.kt b/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsView.kt index 5138ef67..2483c46a 100644 --- a/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsView.kt +++ b/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsView.kt @@ -1,12 +1,20 @@ package com.amazonaws.ivs.reactnative.player +import android.Manifest import android.app.Activity import android.app.PictureInPictureParams +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.util.Log +import android.view.View import android.widget.FrameLayout +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import com.amazonaws.ivs.player.Cue import com.amazonaws.ivs.player.MediaPlayer import com.amazonaws.ivs.player.Player @@ -36,6 +44,7 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte private var player: Player? = null private var streamUri: Uri? = null private val playerListener: Player.Listener? + private var controlReceiver: BroadcastReceiver? = null var playerObserver: Timer? = null private var lastLiveLatency: Long? = null @@ -51,6 +60,10 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte private var lastPosition: Long = 0 private var progressInterval: Long = 1000 + private var wasPlayingBeforeBackground: Boolean = false + private var playInBackground: Boolean = false + private var notificationTitle: String = "" + private var notificationText: String = "" private val eventDispatcher: EventDispatcher @@ -86,6 +99,17 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte context.addLifecycleEventListener(this) + playerView?.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + } + + override fun onViewDetachedFromWindow(v: View) { + if (!playInBackground) { + playerView?.player?.pause() + } + } + }) + playerListener = object : Player.Listener() { override fun onStateChanged(state: Player.State) { onPlayerStateChange(state) @@ -124,6 +148,26 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte } } + controlReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val action = intent?.getStringExtra("action") + if (action == "pause") { + player?.pause() + if (playInBackground) startBackgroundService(false) + } else if (action == "play") { + player?.play() + if (playInBackground) startBackgroundService(true) + } + } + } + + val filter = IntentFilter("IVS_PLAYER_CONTROL") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.registerReceiver(controlReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + context.registerReceiver(controlReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } + player?.addListener(playerListener); addView(playerView) @@ -140,11 +184,28 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte } } + private fun startBackgroundService(isPlaying: Boolean) { + val context = context.applicationContext + val serviceIntent = Intent(context, IVSBackgroundService::class.java) + serviceIntent.putExtra(IVSBackgroundService.EXTRA_IS_PLAYING, isPlaying) + serviceIntent.putExtra(IVSBackgroundService.NOTIFICATION_TITLE, this.notificationTitle) + serviceIntent.putExtra(IVSBackgroundService.NOTIFICATION_TEXT, this.notificationText) + + ContextCompat.startForegroundService(context, serviceIntent) + } + + private fun stopBackgroundService() { + val context = context.applicationContext + val serviceIntent = Intent(context, IVSBackgroundService::class.java) + context.stopService(serviceIntent) + } + fun setStreamUrl(streamUrl: String) { player?.let { player -> val reactContext = context as ReactContext val uri = Uri.parse(streamUrl); this.streamUri = uri; + playInBackground = true finishedLoading = false player.load(uri) @@ -163,6 +224,29 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte player?.isMuted = muted } + fun setPlayInBackground(playInBackground: Boolean) { + if (playInBackground) { + checkAndRequestNotificationPermission() + } + this.playInBackground = playInBackground + } + + fun setNotificationTitle(notificationTitle: String?) { + this.notificationTitle = notificationTitle ?: "" + + if (isInBackground && playInBackground) { + startBackgroundService(player?.state == Player.State.PLAYING) + } + } + + fun setNotificationText(notificationText: String?) { + this.notificationText = notificationText ?: "" + + if (isInBackground && playInBackground) { + startBackgroundService(player?.state == Player.State.PLAYING) + } + } + fun setLooping(shouldLoop: Boolean) { player?.setLooping(shouldLoop) } @@ -434,6 +518,7 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte fun onPlayerStateChange(state: Player.State) { val reactContext = context as ReactContext + this.keepScreenOn = (state == Player.State.PLAYING || state == Player.State.BUFFERING) when (state) { Player.State.PLAYING -> { @@ -686,7 +771,7 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte ) } - fun setProgressInterval(progressInterval: Int) { + fun setProgressInterval(progressInterval: Double) { playerObserver?.cancel() playerObserver?.purge() playerObserver = Timer("observerInterval", false) @@ -701,12 +786,30 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte override fun onHostResume() { isInBackground = false + stopBackgroundService() + + if (wasPlayingBeforeBackground) { + player?.play() + wasPlayingBeforeBackground = false + } } override fun onHostPause() { if (pipEnabled) { isInBackground = true togglePip() + } else { + if (playInBackground && player?.state == Player.State.PLAYING) { + startBackgroundService(true) + return + } + + if (player?.state == Player.State.PLAYING || player?.state == Player.State.BUFFERING) { + wasPlayingBeforeBackground = true + player?.pause() + } else { + wasPlayingBeforeBackground = false + } } } @@ -715,6 +818,7 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte } fun cleanup() { + stopBackgroundService() // Cleanup any remaining sources for (source in preloadSourceMap.values) { source.release() @@ -727,6 +831,11 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte playerObserver?.cancel() playerObserver = null + + if (controlReceiver != null) { + context.unregisterReceiver(controlReceiver) + controlReceiver = null + } } fun TextCue.TextAlignment.toStringValue(): String { @@ -737,4 +846,25 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte } } + private fun checkAndRequestNotificationPermission() { + if (Build.VERSION.SDK_INT >= 33) { + val reactContext = context as ReactContext + val activity = reactContext.currentActivity + + if (ContextCompat.checkSelfPermission( + reactContext, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + activity?.let { + ActivityCompat.requestPermissions( + it, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 101 + ) + } + } + } + } + } diff --git a/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsViewManager.kt b/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsViewManager.kt index f6beeb10..cab39143 100644 --- a/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsViewManager.kt +++ b/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsViewManager.kt @@ -184,11 +184,32 @@ class AmazonIvsViewManager : SimpleViewManager(), override fun setProgressInterval( view: AmazonIvsView?, - value: Int + value: Double ) { view?.setProgressInterval(value) } + override fun setPlayInBackground( + view: AmazonIvsView?, + value: Boolean + ) { + view?.setPlayInBackground(value) + } + + override fun setNotificationTitle( + view: AmazonIvsView?, + value: String? + ) { + view?.setNotificationTitle(value) + } + + override fun setNotificationText( + view: AmazonIvsView?, + value: String? + ) { + view?.setNotificationText(value) + } + override fun preload( view: AmazonIvsView?, url: String, diff --git a/android/src/main/java/com/amazonaws/ivs/reactnative/player/IVSBackgroundService.kt b/android/src/main/java/com/amazonaws/ivs/reactnative/player/IVSBackgroundService.kt new file mode 100644 index 00000000..9eb17ae2 --- /dev/null +++ b/android/src/main/java/com/amazonaws/ivs/reactnative/player/IVSBackgroundService.kt @@ -0,0 +1,117 @@ +package com.amazonaws.ivs.reactnative.player + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.media.app.NotificationCompat.MediaStyle + +class IVSBackgroundService : Service() { + + companion object { + const val CHANNEL_ID = "IVS_BACKGROUND_PLAYBACK_CHANNEL" + const val NOTIFICATION_ID = 101 + + const val ACTION_PLAY = "com.amazonaws.ivs.reactnative.player.PLAY" + const val ACTION_PAUSE = "com.amazonaws.ivs.reactnative.player.PAUSE" + const val ACTION_STOP = "com.amazonaws.ivs.reactnative.player.STOP" + + const val EXTRA_IS_PLAYING = "IS_PLAYING" + const val NOTIFICATION_TITLE = "NOTIFICATION_TITLE" + const val NOTIFICATION_TEXT = "NOTIFICATION_TEXT" + + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val action = intent?.action + + when (action) { + ACTION_STOP -> { + stopForeground(true) + stopSelf() + return START_NOT_STICKY + } + + ACTION_PLAY -> { + sendBroadcast(Intent("IVS_PLAYER_CONTROL").putExtra("action", "play")) + } + + ACTION_PAUSE -> { + sendBroadcast(Intent("IVS_PLAYER_CONTROL").putExtra("action", "pause")) + } + } + + val isPlaying = intent?.getBooleanExtra(EXTRA_IS_PLAYING, true) ?: true + val notificationTitle = intent?.getStringExtra(NOTIFICATION_TITLE) ?: "" + val notificationText = intent?.getStringExtra(NOTIFICATION_TEXT) ?: "" + + createNotificationChannel() + startForeground( + NOTIFICATION_ID, + buildNotification(isPlaying, notificationTitle, notificationText) + ) + + return START_NOT_STICKY + } + + private fun buildNotification( + isPlaying: Boolean, + notificationTitle: String, + notificationText: String + ): Notification { + val packageManager = applicationContext.packageManager + val launchIntent = packageManager.getLaunchIntentForPackage(applicationContext.packageName) + val pendingIntent = PendingIntent.getActivity( + this, 0, launchIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val playIntent = Intent(this, IVSBackgroundService::class.java).apply { action = ACTION_PLAY } + val pauseIntent = Intent(this, IVSBackgroundService::class.java).apply { action = ACTION_PAUSE } + + val pPlay = PendingIntent.getService(this, 1, playIntent, PendingIntent.FLAG_IMMUTABLE) + val pPause = PendingIntent.getService(this, 2, pauseIntent, PendingIntent.FLAG_IMMUTABLE) + + val appIcon = applicationInfo.icon + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(if (notificationTitle != "") notificationTitle else "Player") + .setContentText(if (notificationText != "") notificationText else if (isPlaying) "Playing" else "Paused") + .setSmallIcon(appIcon) + .setContentIntent(pendingIntent) + .setOngoing(isPlaying) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setStyle( + MediaStyle() + .setShowActionsInCompactView(0) + ) + + if (isPlaying) { + builder.addAction(android.R.drawable.ic_media_pause, "Pause", pPause) + } else { + builder.addAction(android.R.drawable.ic_media_play, "Play", pPlay) + } + + return builder.build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + CHANNEL_ID, + "Background Playback", + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(serviceChannel) + } + } +} diff --git a/docs/ivs-player-reference.md b/docs/ivs-player-reference.md index df9e7914..b360457d 100644 --- a/docs/ivs-player-reference.md +++ b/docs/ivs-player-reference.md @@ -211,6 +211,8 @@ Value that specifies how often `onProgress` callback should be called in seconds default: `1` type: `number` +min: `1` +max: `5` ### onTimePoint _(optional)_ @@ -266,6 +268,51 @@ Value that enables picture in picture mode. default: `undefined` type: `boolean` +> **Note:** To support Picture-in-Picture on iOS, you must enable **Background Modes** in your Xcode project settings. + +### playInBackground _(optional)_ + +Value that enables stream playing in background. + +> **Note:** To support background play on iOS, you must enable **Background Modes** in your Xcode project settings. + +> **Note:** To enable background play on Android, you must add the following permissions to your `AndroidManifest.xml`: +> +> ```xml +> +> +> +> +> ``` + +default: `false` +type: `boolean` + +### notificationTitle _(optional)_ + +The main title displayed in the system notification tray when the app is running in the background. + +Android only + +default: `Player` +type: `string` + +### notificationText _(optional)_ + +The description text displayed in the system notification tray. + +Android only + +default: Dynamically changes based on state ("Playing" when active, "Paused" when inactive) +type: `string` + +### showErrorMessage _(optional)_ + +Show banner on error + +default: `undefined` +type: `boolean` + ### onPipChange _(optional)_ Callback that returns changes to the picture in picture state. diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 9a23fffd..d43f61a0 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ package="com.example.amazonivsreactnativeplayer"> + + + + (null); const [detectedQuality, setDetectedQuality] = useState(null); const [initialBufferDuration, setInitialBufferDuration] = useState(0.1); @@ -95,15 +99,7 @@ export default function PlaygroundExample() { const [resizeMode, setResizeMode] = useState( RESIZE_MODES[1] ); - - useAppState({ - onBackground: () => { - pauseInBackground && setPaused(true); - }, - onForeground: () => { - pauseInBackground && setPaused(false); - }, - }); + const [showErrorMessage, setShowErrorMessage] = useState(false); const log = useCallback( (text: string) => { @@ -182,6 +178,8 @@ export default function PlaygroundExample() { autoMaxQuality={autoMaxQuality} breakpoints={breakpoints} onSeek={(newPosition) => console.log('new position', newPosition)} + playInBackground={playInBackground} + notificationTitle="Playing in background" onPlayerStateChange={(state) => { if (state === PlayerState.Buffering) { log(`buffering at ${detectedQuality?.name}`); @@ -233,6 +231,7 @@ export default function PlaygroundExample() { onVideoStatistics={(video) => console.log('onVideoStatistics', video)} onError={(error) => console.log('error', error)} onTimePoint={(timePoint) => console.log('time point', timePoint)} + showErrorMessage={showErrorMessage} > {orientation === Position.PORTRAIT ? ( <> @@ -417,11 +416,17 @@ export default function PlaygroundExample() { testID="rebufferToLive" /> + ; @@ -279,6 +279,8 @@ export function TestPlan() { function runplan() { const plandata = parse(testPlan); + if (!plandata) return; + Object.keys(plandata).forEach((name) => { const lname = name.toLowerCase(); const value = plandata[name]; diff --git a/example/src/useAppState.ts b/example/src/useAppState.ts deleted file mode 100644 index 4c8063cb..00000000 --- a/example/src/useAppState.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useState, useEffect } from 'react'; -import { AppState, AppStateStatus } from 'react-native'; - -type Props = { - onBackground?: () => void; - onForeground?: () => void; - onChange?: (status: AppStateStatus) => void; -}; - -export default function useAppState({ - onChange, - onForeground, - onBackground, -}: Props) { - const [appState, setAppState] = useState(AppState.currentState); - - useEffect(() => { - function handleAppStateChange(nextAppState: AppStateStatus) { - if (nextAppState === 'active' && appState !== 'active') { - onForeground?.(); - } else if ( - appState === 'active' && - nextAppState.match(/inactive|background/) - ) { - onBackground?.(); - } - setAppState(nextAppState); - onChange?.(nextAppState); - } - const state = AppState.addEventListener('change', handleAppStateChange); - - return () => state.remove(); - }, [onChange, onForeground, onBackground, appState]); - - return { appState }; -} diff --git a/ios/AmazonIvs.mm b/ios/AmazonIvs.mm index fde5335f..f8ab2419 100644 --- a/ios/AmazonIvs.mm +++ b/ios/AmazonIvs.mm @@ -28,6 +28,12 @@ + (ComponentDescriptorProvider)componentDescriptorProvider { return concreteComponentDescriptorProvider(); } +// disable view recycling otherwise AV resourses fail to load on recycled view ++ (BOOL)shouldBeRecycled +{ + return NO; +} + - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { static const auto defaultProps = std::make_shared(); @@ -196,6 +202,10 @@ - (void)updateProps:(Props::Shared const &)props if (oldViewProps.progressInterval != newViewProps.progressInterval) { _ivsView.progressInterval = @(newViewProps.progressInterval); } + + if (oldViewProps.playInBackground != newViewProps.playInBackground) { + _ivsView.playInBackground = newViewProps.playInBackground; + } [super updateProps:props oldProps:oldProps]; } diff --git a/ios/AmazonIvsView.swift b/ios/AmazonIvsView.swift index e540c268..d4eae042 100644 --- a/ios/AmazonIvsView.swift +++ b/ios/AmazonIvsView.swift @@ -38,6 +38,7 @@ import UIKit private var _pipController: Any? = nil private var isPipActive: Bool = false + private var wasPlayingBeforeBackground: Bool = false @available(iOS 15, *) private var pipController: AVPictureInPictureController? { @@ -77,6 +78,35 @@ import UIKit player.delegate = self self.playerView.player = player preparePictureInPicture() + addApplicationLifecycleObservers() + } + + private func addApplicationLifecycleObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidEnterBackground(notification:)), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive(notification:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } + + private func removeApplicationLifecycleObservers() { + NotificationCenter.default.removeObserver( + self, + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + NotificationCenter.default.removeObserver( + self, + name: UIApplication.didBecomeActiveNotification, + object: nil + ) } deinit { @@ -84,9 +114,14 @@ import UIKit self.removeProgressObserver() self.removePlayerObserver() self.removeTimePointObserver() + self.removeApplicationLifecycleObservers() } func load(urlString: String) { + if self.playerView.player == nil { + self.playerView.player = self.player + } + finishedLoading = false let url = URL(string: urlString) self.onLoadStart?() @@ -144,6 +179,18 @@ import UIKit } } + public var playInBackground: Bool = false { + didSet { + if playInBackground { + try? AVAudioSession.sharedInstance().setCategory( + .playback, + mode: .moviePlayback + ) + try? AVAudioSession.sharedInstance().setActive(true) + } + } + } + public var pipEnabled: Bool { didSet { guard #available(iOS 15, *), @@ -268,6 +315,11 @@ import UIKit } public func play() { + if UIApplication.shared.applicationState == .background + && pipEnabled == false + { + return + } player.play() } @@ -531,6 +583,9 @@ import UIKit public func player(_ player: IVSPlayer, didFailWithError error: Error) { onError?(["error": error.localizedDescription]) + + player.pause() + self.playerView.player = nil } private func preparePictureInPicture() { @@ -561,7 +616,36 @@ import UIKit self.pipController = pipController pipController.canStartPictureInPictureAutomaticallyFromInline = self.pipEnabled + } + + func applicationDidEnterBackground(notification: Notification) { + if isPipActive { + wasPlayingBeforeBackground = false + return + } + + if playInBackground { + return + } + + if player.state == .playing || player.state == .buffering { + wasPlayingBeforeBackground = true + pause() + } else { + wasPlayingBeforeBackground = false + } + } + + func applicationDidBecomeActive(notification: Notification) { + if isPipActive { + return + } + + if wasPlayingBeforeBackground { + play() + } + wasPlayingBeforeBackground = false } } @available(iOS 15, *) diff --git a/package.json b/package.json index 1cd10c72..9dcc3f14 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "postinstall": "husky", "example": "yarn --cwd example", "pods": "cd example && pod-install --quiet", - "bootstrap": "yarn && yarn pods", + "bootstrap": "yarn && yarn pods && yarn markbuild", "e2e:ios": "yarn bootstrap && yarn e2e:reset && yarn e2e:build:ios && yarn e2e:test:ios", "e2e:android": "yarn bootstrap && yarn e2e:build:android && yarn e2e:test:android", "e2e:reset": "detox clean-framework-cache && detox build-framework-cache", diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 4fbaad96..4eea03d7 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { UIManager } from 'react-native'; import type { IVSPlayerRef } from '../types'; -import IVSPlayer from '../IVSPlayer'; +import IVSPlayer from '../components/IVSPlayer'; const URL = 'https://fcc3ddae59ed.us-west-2.playback.live-video.net/api/video/v1/us-west-2.893648527354.channel.DmumNckWFTqz.m3u8'; diff --git a/src/AmazonIvsViewNativeComponent.ts b/src/components/AmazonIvsViewNativeComponent.ts similarity index 96% rename from src/AmazonIvsViewNativeComponent.ts rename to src/components/AmazonIvsViewNativeComponent.ts index c538d89a..d07e550e 100644 --- a/src/AmazonIvsViewNativeComponent.ts +++ b/src/components/AmazonIvsViewNativeComponent.ts @@ -36,7 +36,10 @@ export interface NativeProps extends ViewProps { maxBitrate?: Int32; initialBufferDuration?: Double; pipEnabled?: boolean; - progressInterval?: Int32; + progressInterval?: Double; + playInBackground?: boolean; + notificationTitle?: string; + notificationText?: string; onLoadStart?: DirectEventHandler<{}>; onVideoStatistics?: DirectEventHandler<{ videoData: { diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 00000000..97f27b05 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,70 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Animated, StyleSheet, Text } from 'react-native'; + +interface ErrorNotificationProps { + message: string | null; +} + +export const ErrorNotification = ({ message }: ErrorNotificationProps) => { + const [activeMessage, setActiveMessage] = useState(null); + const [showNotification, setShowNotification] = useState(false); + const animationValue = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (message && message !== activeMessage) { + setActiveMessage(message); + setShowNotification(true); + + Animated.timing(animationValue, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }).start(); + } else { + Animated.timing(animationValue, { + toValue: 0, + duration: 500, + useNativeDriver: true, + }).start(({ finished }) => { + if (finished) { + setShowNotification(false); + } + }); + } + }, [message]); + + if (!showNotification) return null; + + return ( + + {activeMessage} + + ); +}; + +const styles = StyleSheet.create({ + errorContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 9999, + backgroundColor: '#ff4d4f', + padding: '16px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + errorText: { + color: '#fff', + fontSize: 14, + fontWeight: 600, + }, +}); diff --git a/src/IVSPlayer.tsx b/src/components/IVSPlayer.tsx similarity index 87% rename from src/IVSPlayer.tsx rename to src/components/IVSPlayer.tsx index 7b3f4702..8e2694d6 100644 --- a/src/IVSPlayer.tsx +++ b/src/components/IVSPlayer.tsx @@ -1,4 +1,10 @@ -import React, { useEffect, useImperativeHandle, useRef } from 'react'; +import React, { + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; import { Platform, StyleSheet, @@ -6,11 +12,6 @@ import { type NativeSyntheticEvent, type ViewStyle, } from 'react-native'; -import AmazonIvsViewNativeComponent, { - Commands, -} from './AmazonIvsViewNativeComponent'; -import type { LogLevel, PlayerState } from './enums'; -import { createSourceWrapper } from './source'; import type { IVSPlayerRef, PlayerData, @@ -20,7 +21,15 @@ import type { TextCue, TextMetadataCue, VideoData, -} from './types'; +} from '../types'; +import type { LogLevel, PlayerState } from '../types/enums'; +import { createSourceWrapper } from '../types/source'; +import AmazonIvsViewNativeComponent, { + Commands, +} from './AmazonIvsViewNativeComponent'; +import { ErrorNotification } from './ErrorNotification'; + +const MAX_PROGRESS_INTERVAL = 5; export type Props = { style?: ViewStyle; @@ -44,6 +53,10 @@ export type Props = { maxBitrate?: number; initialBufferDuration?: number; pipEnabled?: boolean; + showErrorMessage?: boolean; + playInBackground?: boolean; + notificationTitle?: string; + notificationText?: string; onSeek?(position: number): void; onData?(data: PlayerData): void; onVideoStatistics?(data: VideoData): void; @@ -85,7 +98,7 @@ const IVSPlayerContainer = React.forwardRef( playbackRate, pipEnabled, logLevel, - progressInterval, + progressInterval = 1, volume, quality, autoMaxQuality, @@ -93,6 +106,10 @@ const IVSPlayerContainer = React.forwardRef( breakpoints = [], maxBitrate, initialBufferDuration, + showErrorMessage, + playInBackground = false, + notificationTitle, + notificationText, onSeek, onData, onVideoStatistics, @@ -115,6 +132,7 @@ const IVSPlayerContainer = React.forwardRef( ) => { const mediaPlayerRef = useRef(null); const initialized = useRef(false); + const [errorMessage, setErrorMessage] = useState(); const preload = (url: string) => { if (!mediaPlayerRef.current || !url) return null; @@ -298,6 +316,13 @@ const IVSPlayerContainer = React.forwardRef( const onErrorHandler = (event: NativeSyntheticEvent<{ error: string }>) => { const { error } = event.nativeEvent; + + if (showErrorMessage) { + setErrorMessage(error); + setTimeout(() => { + setErrorMessage(null); + }, 5000); + } onError?.(error); }; @@ -308,8 +333,17 @@ const IVSPlayerContainer = React.forwardRef( onTimePoint?.(position); }; + const constrainedProgressInterval = useMemo(() => { + if (!progressInterval || progressInterval <= 0.1) { + return 0.1; + } + + return Math.min(progressInterval, MAX_PROGRESS_INTERVAL); + }, [progressInterval]); + return ( + ( streamUrl={streamUrl} logLevel={logLevel} resizeMode={resizeMode} - progressInterval={progressInterval} + progressInterval={constrainedProgressInterval} volume={volume} quality={quality} initialBufferDuration={initialBufferDuration} @@ -331,6 +365,9 @@ const IVSPlayerContainer = React.forwardRef( breakpoints={breakpoints} maxBitrate={maxBitrate} pipEnabled={pipEnabled} + playInBackground={playInBackground} + notificationTitle={notificationTitle} + notificationText={notificationText} onVideoStatistics={onVideoStatisticsHandler} onData={onDataHandler} onSeek={onSeekHandler} diff --git a/src/index.ts b/src/index.ts index 40f1c834..416b7096 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ -export * from './AmazonIvsViewNativeComponent'; -export { default as AmazonIvsView } from './AmazonIvsViewNativeComponent'; -export * from './enums'; -export { default } from './IVSPlayer'; -export type { Props as IVSPlayerProps } from './IVSPlayer'; +export * from './components/AmazonIvsViewNativeComponent'; +export { default as AmazonIvsView } from './components/AmazonIvsViewNativeComponent'; +export { default } from './components/IVSPlayer'; +export type { Props as IVSPlayerProps } from './components/IVSPlayer'; export * from './types'; +export * from './types/enums'; diff --git a/src/enums.ts b/src/types/enums.ts similarity index 100% rename from src/enums.ts rename to src/types/enums.ts diff --git a/src/types.ts b/src/types/index.ts similarity index 100% rename from src/types.ts rename to src/types/index.ts diff --git a/src/source.ts b/src/types/source.ts similarity index 91% rename from src/source.ts rename to src/types/source.ts index d77826ec..ffa61b8e 100644 --- a/src/source.ts +++ b/src/types/source.ts @@ -1,4 +1,4 @@ -import type { Source } from './types'; +import type { Source } from '.'; let sourceId = 0;