From e00764805b62498ccf1bd2efd2937650846bd9fc Mon Sep 17 00:00:00 2001 From: Juozas Petkelis Date: Thu, 27 Nov 2025 14:23:55 +0200 Subject: [PATCH 1/4] fix: solve test crash and life improments --- .../ivs/reactnative/player/AmazonIvsView.kt | 24 +++++++ docs/ivs-player-reference.md | 2 + example/src/screens/TestPlan.tsx | 12 ++-- ios/AmazonIvsView.swift | 66 +++++++++++++++++++ src/IVSPlayer.tsx | 14 +++- 5 files changed, 111 insertions(+), 7 deletions(-) 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 5138ef6..c043d2e 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 @@ -6,6 +6,7 @@ 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 com.amazonaws.ivs.player.Cue import com.amazonaws.ivs.player.MediaPlayer @@ -51,6 +52,7 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte private var lastPosition: Long = 0 private var progressInterval: Long = 1000 + private var wasPlayingBeforeBackground: Boolean = false private val eventDispatcher: EventDispatcher @@ -86,6 +88,15 @@ 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) { + playerView?.player?.pause() + } + }) + playerListener = object : Player.Listener() { override fun onStateChanged(state: Player.State) { onPlayerStateChange(state) @@ -434,6 +445,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 -> { @@ -701,12 +713,24 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte override fun onHostResume() { isInBackground = false + + if (wasPlayingBeforeBackground) { + player?.play() + wasPlayingBeforeBackground = false + } } override fun onHostPause() { if (pipEnabled) { isInBackground = true togglePip() + } else { + if (player?.state == Player.State.PLAYING || player?.state == Player.State.BUFFERING) { + wasPlayingBeforeBackground = true + player?.pause() + } else { + wasPlayingBeforeBackground = false + } } } diff --git a/docs/ivs-player-reference.md b/docs/ivs-player-reference.md index df9e791..0a70e35 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: `99999999` ### onTimePoint _(optional)_ diff --git a/example/src/screens/TestPlan.tsx b/example/src/screens/TestPlan.tsx index 1c739be..10b3064 100644 --- a/example/src/screens/TestPlan.tsx +++ b/example/src/screens/TestPlan.tsx @@ -1,5 +1,3 @@ -import * as React from 'react'; -import { parse } from 'yaml'; import IVSPlayer, { IVSPlayerProps, IVSPlayerRef, @@ -7,18 +5,20 @@ import IVSPlayer, { Quality, Source, } from 'amazon-ivs-react-native-player'; +import * as React from 'react'; import { StyleSheet, View } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; import { Button, - TextInput, Chip, + IconButton, Subheading, Text, + TextInput, ToggleButton, - IconButton, } from 'react-native-paper'; -import { ScrollView } from 'react-native-gesture-handler'; import { proxy, useSnapshot } from 'valtio'; +import { parse } from 'yaml'; type PlanProps = Record; @@ -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/ios/AmazonIvsView.swift b/ios/AmazonIvsView.swift index e540c26..ee5d929 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,6 +114,14 @@ import UIKit self.removeProgressObserver() self.removePlayerObserver() self.removeTimePointObserver() + self.removeApplicationLifecycleObservers() + } + + override public func didMoveToWindow() { + super.didMoveToWindow() + if self.window == nil { + self.player.pause() + } } func load(urlString: String) { @@ -268,6 +306,9 @@ import UIKit } public func play() { + if UIApplication.shared.applicationState == .background && pipEnabled == false { + return + } player.play() } @@ -561,7 +602,32 @@ import UIKit self.pipController = pipController pipController.canStartPictureInPictureAutomaticallyFromInline = self.pipEnabled + } + + func applicationDidEnterBackground(notification: Notification) { + if isPipActive { + wasPlayingBeforeBackground = false + 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/src/IVSPlayer.tsx b/src/IVSPlayer.tsx index 7b3f470..c8a2996 100644 --- a/src/IVSPlayer.tsx +++ b/src/IVSPlayer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useImperativeHandle, useRef } from 'react'; +import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react'; import { Platform, StyleSheet, @@ -22,6 +22,8 @@ import type { VideoData, } from './types'; +const MAX_PROGRESS_INTERVAL = 99_999_999; + export type Props = { style?: ViewStyle; testID?: string; @@ -308,6 +310,14 @@ const IVSPlayerContainer = React.forwardRef( onTimePoint?.(position); }; + const constrainedProgressInterval = useMemo(() => { + if (!progressInterval || progressInterval < 1) { + return 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} From 07715376209716518ac2ab9aad93e42b41a88fe4 Mon Sep 17 00:00:00 2001 From: Juozas Petkelis Date: Fri, 28 Nov 2025 16:41:21 +0200 Subject: [PATCH 2/4] fix: disable view recycling on ios, add error message --- docs/ivs-player-reference.md | 7 ++ example/src/screens/PlaygroundExample.tsx | 45 +++++++----- ios/AmazonIvs.mm | 6 ++ ios/AmazonIvsView.swift | 16 +++-- package.json | 2 +- src/__tests__/index.test.tsx | 2 +- .../AmazonIvsViewNativeComponent.ts | 0 src/components/ErrorNotification.tsx | 70 +++++++++++++++++++ src/{ => components}/IVSPlayer.tsx | 32 +++++++-- src/index.ts | 10 +-- src/{ => types}/enums.ts | 0 src/{types.ts => types/index.ts} | 0 src/{ => types}/source.ts | 2 +- 13 files changed, 154 insertions(+), 38 deletions(-) rename src/{ => components}/AmazonIvsViewNativeComponent.ts (100%) create mode 100644 src/components/ErrorNotification.tsx rename src/{ => components}/IVSPlayer.tsx (94%) rename src/{ => types}/enums.ts (100%) rename src/{types.ts => types/index.ts} (100%) rename src/{ => types}/source.ts (91%) diff --git a/docs/ivs-player-reference.md b/docs/ivs-player-reference.md index 0a70e35..bbdd109 100644 --- a/docs/ivs-player-reference.md +++ b/docs/ivs-player-reference.md @@ -268,6 +268,13 @@ Value that enables picture in picture mode. default: `undefined` type: `boolean` +### 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/src/screens/PlaygroundExample.tsx b/example/src/screens/PlaygroundExample.tsx index c6dd4a9..0aca835 100644 --- a/example/src/screens/PlaygroundExample.tsx +++ b/example/src/screens/PlaygroundExample.tsx @@ -1,6 +1,6 @@ -import * as React from 'react'; -import { useState, useCallback, useEffect } from 'react'; -import { Dimensions, StyleSheet, View, ScrollView } from 'react-native'; +import Slider from '@react-native-community/slider'; +import { useNavigation } from '@react-navigation/native'; +import type { StackNavigationProp } from '@react-navigation/stack'; import IVSPlayer, { IVSPlayerRef, LogLevel, @@ -8,28 +8,33 @@ import IVSPlayer, { Quality, ResizeMode, } from 'amazon-ivs-react-native-player'; +import * as React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { + Dimensions, + Platform, + ScrollView, + StyleSheet, + View, +} from 'react-native'; import { - IconButton, ActivityIndicator, Button, - Text, + Chip, + IconButton, Portal, + Text, Title, - Chip, } from 'react-native-paper'; -import { Platform } from 'react-native'; -import Slider from '@react-native-community/slider'; -import type { StackNavigationProp } from '@react-navigation/stack'; -import { useNavigation } from '@react-navigation/native'; -import { parseSecondsToString } from '../helpers'; -import SettingsItem from '../components/SettingsItem'; -import SettingsSliderItem from '../components/SettingsSliderItem'; +import type { RootStackParamList } from '../App'; import LogLevelPicker from '../components/LogLevelPicker'; -import { Position, URL } from '../constants'; +import OptionPicker from '../components/OptionPicker'; import SettingsInputItem from '../components/SettingsInputItem'; +import SettingsItem from '../components/SettingsItem'; +import SettingsSliderItem from '../components/SettingsSliderItem'; import SettingsSwitchItem from '../components/SettingsSwitchItem'; -import type { RootStackParamList } from '../App'; -import OptionPicker from '../components/OptionPicker'; +import { Position, URL } from '../constants'; +import { parseSecondsToString } from '../helpers'; import useAppState from '../useAppState'; const INITIAL_PLAYBACK_RATE = 1; @@ -95,6 +100,7 @@ export default function PlaygroundExample() { const [resizeMode, setResizeMode] = useState( RESIZE_MODES[1] ); + const [showErrorMessage, setShowErrorMessage] = useState(false); useAppState({ onBackground: () => { @@ -233,6 +239,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 ? ( <> @@ -422,6 +429,12 @@ export default function PlaygroundExample() { onValueChange={setPauseInBackground} testID="pauseInBackground" /> + (); } +// 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(); diff --git a/ios/AmazonIvsView.swift b/ios/AmazonIvsView.swift index ee5d929..a279f48 100644 --- a/ios/AmazonIvsView.swift +++ b/ios/AmazonIvsView.swift @@ -117,14 +117,11 @@ import UIKit self.removeApplicationLifecycleObservers() } - override public func didMoveToWindow() { - super.didMoveToWindow() - if self.window == nil { - self.player.pause() + func load(urlString: String) { + if self.playerView.player == nil { + self.playerView.player = self.player } - } - func load(urlString: String) { finishedLoading = false let url = URL(string: urlString) self.onLoadStart?() @@ -306,7 +303,9 @@ import UIKit } public func play() { - if UIApplication.shared.applicationState == .background && pipEnabled == false { + if UIApplication.shared.applicationState == .background + && pipEnabled == false + { return } player.play() @@ -572,6 +571,9 @@ import UIKit public func player(_ player: IVSPlayer, didFailWithError error: Error) { onError?(["error": error.localizedDescription]) + + player.pause() + self.playerView.player = nil } private func preparePictureInPicture() { diff --git a/package.json b/package.json index 1cd10c7..9dcc3f1 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 4fbaad9..4eea03d 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 100% rename from src/AmazonIvsViewNativeComponent.ts rename to src/components/AmazonIvsViewNativeComponent.ts diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000..97f27b0 --- /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 94% rename from src/IVSPlayer.tsx rename to src/components/IVSPlayer.tsx index c8a2996..ca1993a 100644 --- a/src/IVSPlayer.tsx +++ b/src/components/IVSPlayer.tsx @@ -1,4 +1,10 @@ -import React, { useEffect, useImperativeHandle, useMemo, 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,13 @@ 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 = 99_999_999; @@ -46,6 +53,7 @@ export type Props = { maxBitrate?: number; initialBufferDuration?: number; pipEnabled?: boolean; + showErrorMessage?: boolean; onSeek?(position: number): void; onData?(data: PlayerData): void; onVideoStatistics?(data: VideoData): void; @@ -95,6 +103,7 @@ const IVSPlayerContainer = React.forwardRef( breakpoints = [], maxBitrate, initialBufferDuration, + showErrorMessage, onSeek, onData, onVideoStatistics, @@ -117,6 +126,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; @@ -300,6 +310,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); }; @@ -320,6 +337,7 @@ const IVSPlayerContainer = React.forwardRef( return ( + Date: Mon, 1 Dec 2025 16:18:03 +0200 Subject: [PATCH 3/4] feat: add background play, android notification --- android/build.gradle | 1 + android/src/main/AndroidManifest.xml | 8 +- .../ivs/reactnative/player/AmazonIvsView.kt | 109 +++++++++++++++- .../player/AmazonIvsViewManager.kt | 21 ++++ .../player/IVSBackgroundService.kt | 117 ++++++++++++++++++ docs/ivs-player-reference.md | 38 ++++++ .../android/app/src/main/AndroidManifest.xml | 4 + example/src/screens/PlaygroundExample.tsx | 20 +-- example/src/useAppState.ts | 36 ------ ios/AmazonIvs.mm | 4 + ios/AmazonIvsView.swift | 16 +++ .../AmazonIvsViewNativeComponent.ts | 3 + src/components/IVSPlayer.tsx | 9 ++ 13 files changed, 334 insertions(+), 52 deletions(-) create mode 100644 android/src/main/java/com/amazonaws/ivs/reactnative/player/IVSBackgroundService.kt delete mode 100644 example/src/useAppState.ts diff --git a/android/build.gradle b/android/build.gradle index 7323073..a0b81e5 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 f8eb395..ac827a7 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 c043d2e..52a679c 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,13 +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 @@ -37,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 @@ -53,6 +61,9 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte 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 @@ -93,7 +104,9 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte } override fun onViewDetachedFromWindow(v: View) { - playerView?.player?.pause() + if (!playInBackground) { + playerView?.player?.pause() + } } }) @@ -135,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) @@ -151,11 +184,29 @@ 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; + checkAndRequestNotificationPermission() + playInBackground = true finishedLoading = false player.load(uri) @@ -174,6 +225,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) } @@ -713,6 +787,7 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte override fun onHostResume() { isInBackground = false + stopBackgroundService() if (wasPlayingBeforeBackground) { player?.play() @@ -725,6 +800,11 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte 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() @@ -739,6 +819,7 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte } fun cleanup() { + stopBackgroundService() // Cleanup any remaining sources for (source in preloadSourceMap.values) { source.release() @@ -751,6 +832,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 { @@ -761,4 +847,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 f6beeb1..f9f9f75 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 @@ -189,6 +189,27 @@ class AmazonIvsViewManager : SimpleViewManager(), 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 0000000..9eb17ae --- /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 bbdd109..ae95f81 100644 --- a/docs/ivs-player-reference.md +++ b/docs/ivs-player-reference.md @@ -268,6 +268,44 @@ 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 diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 9a23fff..d43f61a 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); @@ -102,15 +101,6 @@ export default function PlaygroundExample() { ); const [showErrorMessage, setShowErrorMessage] = useState(false); - useAppState({ - onBackground: () => { - pauseInBackground && setPaused(true); - }, - onForeground: () => { - pauseInBackground && setPaused(false); - }, - }); - const log = useCallback( (text: string) => { console.log(text); @@ -188,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}`); @@ -424,9 +416,9 @@ export default function PlaygroundExample() { testID="rebufferToLive" /> 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 cde3ce4..f8ab241 100644 --- a/ios/AmazonIvs.mm +++ b/ios/AmazonIvs.mm @@ -202,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 a279f48..d4eae04 100644 --- a/ios/AmazonIvsView.swift +++ b/ios/AmazonIvsView.swift @@ -179,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, *), @@ -612,6 +624,10 @@ import UIKit return } + if playInBackground { + return + } + if player.state == .playing || player.state == .buffering { wasPlayingBeforeBackground = true pause() diff --git a/src/components/AmazonIvsViewNativeComponent.ts b/src/components/AmazonIvsViewNativeComponent.ts index c538d89..de2ee5e 100644 --- a/src/components/AmazonIvsViewNativeComponent.ts +++ b/src/components/AmazonIvsViewNativeComponent.ts @@ -37,6 +37,9 @@ export interface NativeProps extends ViewProps { initialBufferDuration?: Double; pipEnabled?: boolean; progressInterval?: Int32; + playInBackground?: boolean; + notificationTitle?: string; + notificationText?: string; onLoadStart?: DirectEventHandler<{}>; onVideoStatistics?: DirectEventHandler<{ videoData: { diff --git a/src/components/IVSPlayer.tsx b/src/components/IVSPlayer.tsx index ca1993a..d36e297 100644 --- a/src/components/IVSPlayer.tsx +++ b/src/components/IVSPlayer.tsx @@ -54,6 +54,9 @@ export type Props = { initialBufferDuration?: number; pipEnabled?: boolean; showErrorMessage?: boolean; + playInBackground?: boolean; + notificationTitle?: string; + notificationText?: string; onSeek?(position: number): void; onData?(data: PlayerData): void; onVideoStatistics?(data: VideoData): void; @@ -104,6 +107,9 @@ const IVSPlayerContainer = React.forwardRef( maxBitrate, initialBufferDuration, showErrorMessage, + playInBackground = false, + notificationTitle, + notificationText, onSeek, onData, onVideoStatistics, @@ -359,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} From 0dc5c350c5e00f70ace51c62c5ec94d9cf4ce88e Mon Sep 17 00:00:00 2001 From: Juozas Petkelis Date: Tue, 2 Dec 2025 20:40:51 +0200 Subject: [PATCH 4/4] feat: progressInterval clamped from 0.1 to 5 --- .../com/amazonaws/ivs/reactnative/player/AmazonIvsView.kt | 5 ++--- .../ivs/reactnative/player/AmazonIvsViewManager.kt | 2 +- docs/ivs-player-reference.md | 2 +- src/components/AmazonIvsViewNativeComponent.ts | 2 +- src/components/IVSPlayer.tsx | 8 ++++---- 5 files changed, 9 insertions(+), 10 deletions(-) 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 52a679c..2483c46 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 @@ -205,7 +205,6 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte val reactContext = context as ReactContext val uri = Uri.parse(streamUrl); this.streamUri = uri; - checkAndRequestNotificationPermission() playInBackground = true finishedLoading = false @@ -226,7 +225,7 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte } fun setPlayInBackground(playInBackground: Boolean) { - if(playInBackground){ + if (playInBackground) { checkAndRequestNotificationPermission() } this.playInBackground = playInBackground @@ -772,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) 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 f9f9f75..cab3914 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,7 +184,7 @@ class AmazonIvsViewManager : SimpleViewManager(), override fun setProgressInterval( view: AmazonIvsView?, - value: Int + value: Double ) { view?.setProgressInterval(value) } diff --git a/docs/ivs-player-reference.md b/docs/ivs-player-reference.md index ae95f81..b360457 100644 --- a/docs/ivs-player-reference.md +++ b/docs/ivs-player-reference.md @@ -212,7 +212,7 @@ Value that specifies how often `onProgress` callback should be called in seconds default: `1` type: `number` min: `1` -max: `99999999` +max: `5` ### onTimePoint _(optional)_ diff --git a/src/components/AmazonIvsViewNativeComponent.ts b/src/components/AmazonIvsViewNativeComponent.ts index de2ee5e..d07e550 100644 --- a/src/components/AmazonIvsViewNativeComponent.ts +++ b/src/components/AmazonIvsViewNativeComponent.ts @@ -36,7 +36,7 @@ export interface NativeProps extends ViewProps { maxBitrate?: Int32; initialBufferDuration?: Double; pipEnabled?: boolean; - progressInterval?: Int32; + progressInterval?: Double; playInBackground?: boolean; notificationTitle?: string; notificationText?: string; diff --git a/src/components/IVSPlayer.tsx b/src/components/IVSPlayer.tsx index d36e297..8e2694d 100644 --- a/src/components/IVSPlayer.tsx +++ b/src/components/IVSPlayer.tsx @@ -29,7 +29,7 @@ import AmazonIvsViewNativeComponent, { } from './AmazonIvsViewNativeComponent'; import { ErrorNotification } from './ErrorNotification'; -const MAX_PROGRESS_INTERVAL = 99_999_999; +const MAX_PROGRESS_INTERVAL = 5; export type Props = { style?: ViewStyle; @@ -98,7 +98,7 @@ const IVSPlayerContainer = React.forwardRef( playbackRate, pipEnabled, logLevel, - progressInterval, + progressInterval = 1, volume, quality, autoMaxQuality, @@ -334,8 +334,8 @@ const IVSPlayerContainer = React.forwardRef( }; const constrainedProgressInterval = useMemo(() => { - if (!progressInterval || progressInterval < 1) { - return 1; + if (!progressInterval || progressInterval <= 0.1) { + return 0.1; } return Math.min(progressInterval, MAX_PROGRESS_INTERVAL);