From 0c09b97962435e85efff5c0df31cf7356487f5a7 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Tue, 9 Nov 2021 14:37:50 -0500 Subject: [PATCH] Add native audio player --- assets/js/NativeShell.staticjs | 1 + assets/js/plugins/NativeAudioPlayer.staticjs | 174 +++++++++++++++++++ components/AudioPlayer.js | 72 ++++++++ components/NativeShellWebView.js | 9 + screens/HomeScreen.js | 2 + utils/StaticScriptLoader.js | 2 + 6 files changed, 260 insertions(+) create mode 100644 assets/js/plugins/NativeAudioPlayer.staticjs create mode 100644 components/AudioPlayer.js diff --git a/assets/js/NativeShell.staticjs b/assets/js/NativeShell.staticjs index c7b4858..6771d35 100644 --- a/assets/js/NativeShell.staticjs +++ b/assets/js/NativeShell.staticjs @@ -119,6 +119,7 @@ window.NativeShell = { return []; } return [ + // 'NativeAudioPlayer', 'NativeVideoPlayer' ]; }, diff --git a/assets/js/plugins/NativeAudioPlayer.staticjs b/assets/js/plugins/NativeAudioPlayer.staticjs new file mode 100644 index 0000000..e505151 --- /dev/null +++ b/assets/js/plugins/NativeAudioPlayer.staticjs @@ -0,0 +1,174 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +class NativeAudioPlayer { + constructor({ events, playbackManager, loading }) { + // declare public fields because iOS < 14 does not support them properly + this.name = 'ExpoAudioPlayer'; + this.type = 'mediaplayer'; + this.id = 'expoaudioplayer'; + this.priority = -1; + this.isLocalPlayer = true; + + this.uri = null; + this._isPlaying = false; + this.positionMillis = 0; + + // local references to web classes + this.events = events; + this.playbackManager = playbackManager; + this.loading = loading; + + // expose this instance to the global scope + window[this.name] = this; + } + + // Called by web when the player should be destroyed + destroy() { + // Reset local player state + this.streamInfo = null; + this.item = null; + this.mediaSource = null; + + this.uri = null; + this._isPlaying = false; + this.positionMillis = 0; + } + + // Report playback status changes to web + _reportStatus(status) { + // The item uri has changed + if (status.uri !== this.uri) { + this.uri = status.uri; + + if (this.uri === null) { + // If the player was closed manually, + // we need to tell web to stop playback + if (status.didPlayerCloseManually) { + this.playbackManager.stop(this); + } + // Notify web that playback has stopped + this._safelyTriggerEvent('stopped'); + } else { + // Set flag used by web to show playback has started + this.streamInfo.started = true; + } + } + + // Playback has stopped don't report events + if (this.uri === null) { + return; + } + + // The playing state has changed + if (status.isPlaying !== this._isPlaying) { + this._isPlaying = status.isPlaying; + this._safelyTriggerEvent(this._isPlaying ? 'playing' : 'pause'); + } + + // The playback position has changed + if (status.positionMillis !== this.positionMillis) { + this.positionMillis = status.positionMillis; + this._safelyTriggerEvent('timeupdate'); + } + } + + // Trigger events in web and handle any exceptions + _safelyTriggerEvent(event) { + try { + this.events.trigger(this, event); + } catch (ex) { + console.error(ex.name + '\n' + ex.message); + } + } + + // This player only supports audio + canPlayMediaType(mediaType) { + return mediaType === 'Audio'; + } + + // Any audio items are supported when enabled + canPlayItem() { + // return window.ExpoAppSettings.isNativeAudioPlayerEnabled; + // FIXME: Add setting for this also + return true; + } + + // Returns the currently playing item + currentItem() { + return this.item; + } + + // Returns the url of the currently playing item (required by web for the "remote control" screen) + currentSrc() { + return (this.streamInfo && this.streamInfo.url) || ''; + } + + // Returns the current media source + currentMediaSource() { + return this.mediaSource; + } + + // Returns the current player position in ms + currentTime() { + return this.positionMillis; + } + + // Dummy method (required by web for live tv) + duration() { + return 1; + } + + // Dummy method (required by web) + isMuted() { + return false; + } + + // Return the playing state (required by web) + isPlaying() { + return this._isPlaying; + } + + // Return the paused state (required by web) + paused() { + return !this._isPlaying; + } + + // Called by web to begin playback + play(streamInfo) { + postExpoEvent(this.name + '.play', streamInfo); + + // Save the currently playing item + this.streamInfo = streamInfo; + this.item = streamInfo.item; + this.mediaSource = streamInfo.mediaSource; + + // We don't know when the audio player is actually + // displayed so delay hiding the loading indicator + // for a few seconds + return new Promise(resolve => window.setTimeout(resolve, 1000)); + } + + playPause() { + postExpoEvent(this.name + '.playPause'); + } + + stop(destroy) { + postExpoEvent(this.name + '.stop', destroy); + return Promise.resolve(); + } + + // Dummy method (required by web) + volume() { + return 100; + } + + getDeviceProfile() { + return Promise.resolve(window.ExpoVideoProfile || {}); + } +} + +window.NativeAudioPlayer = () => NativeAudioPlayer; diff --git a/components/AudioPlayer.js b/components/AudioPlayer.js new file mode 100644 index 0000000..635cff4 --- /dev/null +++ b/components/AudioPlayer.js @@ -0,0 +1,72 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Audio } from 'expo-av'; +import { observer } from 'mobx-react-lite'; +import React, { useEffect, useState } from 'react'; + +import MediaTypes from '../constants/MediaTypes'; +import { useStores } from '../hooks/useStores'; +import { msToTicks } from '../utils/Time'; + +const AudioPlayer = observer(() => { + const { rootStore } = useStores(); + + const [ player, setPlayer ] = useState(); + + // Set the audio mode when the audio player is created + useEffect(() => { + Audio.setAudioModeAsync({ + interruptionModeAndroid: Audio.INTERRUPTION_MODE_ANDROID_DO_NOT_MIX, + interruptionModeIOS: Audio.INTERRUPTION_MODE_IOS_DO_NOT_MIX, + playsInSilentModeIOS: true + }); + + return () => { + player?.unloadAsync(); + }; + }, []); + + // Update the player when media type or uri changes + useEffect(() => { + if (rootStore.mediaStore.type === MediaTypes.Audio) { + setPlayer(Audio.Sound.createAsync({ + uri: rootStore.mediaStore.uri + }, { + positionMillis: rootStore.mediaStore.positionMillis, + shouldPlay: true + }, ({ isPlaying, positionMillis }) => { + rootStore.mediaStore.isPlaying = isPlaying; + rootStore.mediaStore.positionTicks = msToTicks(positionMillis); + })); + } + }, [ rootStore.mediaStore.type, rootStore.mediaStore.uri ]); + + // Update the play/pause state when the store indicates it should + useEffect(() => { + if (rootStore.mediaStore.type === MediaTypes.Audio && rootStore.mediaStore.shouldPlayPause) { + if (rootStore.mediaStore.isPlaying) { + player.current?.pauseAsync(); + } else { + player.current?.playAsync(); + } + rootStore.mediaStore.shouldPlayPause = false; + } + }, [ rootStore.mediaStore.shouldPlayPause ]); + + // Stop the player when the store indicates it should stop playback + useEffect(() => { + if (rootStore.mediaStore.type === MediaTypes.Audio && rootStore.mediaStore.shouldStop) { + player?.stopAsync(); + player?.unloadAsync(); + rootStore.mediaStore.shouldStop = false; + } + }, [ rootStore.mediaStore.shouldStop ]); + + return <>; +}); + +export default AudioPlayer; diff --git a/components/NativeShellWebView.js b/components/NativeShellWebView.js index c119741..e841663 100644 --- a/components/NativeShellWebView.js +++ b/components/NativeShellWebView.js @@ -49,6 +49,7 @@ function postExpoEvent(event, data) { })); } +${StaticScriptLoader.scripts.NativeAudioPlayer} ${StaticScriptLoader.scripts.NativeVideoPlayer} ${StaticScriptLoader.scripts.NativeShell} @@ -97,15 +98,23 @@ true; deactivateKeepAwake(); } break; + case 'ExpoAudioPlayer.play': + rootStore.mediaStore.type = MediaTypes.Audio; + rootStore.mediaStore.uri = data.url; + rootStore.mediaStore.posterUri = data.backdropUrl; + rootStore.mediaStore.positionTicks = data.playerStartPositionTicks; + break; case 'ExpoVideoPlayer.play': rootStore.mediaStore.type = MediaTypes.Video; rootStore.mediaStore.uri = data.url; rootStore.mediaStore.posterUri = data.backdropUrl; rootStore.mediaStore.positionTicks = data.playerStartPositionTicks; break; + case 'ExpoAudioPlayer.playPause': case 'ExpoVideoPlayer.playPause': rootStore.mediaStore.shouldPlayPause = true; break; + case 'ExpoAudioPlayer.stop': case 'ExpoVideoPlayer.stop': rootStore.mediaStore.shouldStop = true; break; diff --git a/screens/HomeScreen.js b/screens/HomeScreen.js index 67d47b3..92f53f7 100644 --- a/screens/HomeScreen.js +++ b/screens/HomeScreen.js @@ -11,6 +11,7 @@ import { BackHandler, Platform, StyleSheet, View } from 'react-native'; import { ThemeContext } from 'react-native-elements'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import AudioPlayer from '../components/AudioPlayer'; import ErrorView from '../components/ErrorView'; import NativeShellWebView from '../components/NativeShellWebView'; import VideoPlayer from '../components/VideoPlayer'; @@ -187,6 +188,7 @@ const HomeScreen = observer(() => { setIsLoading(false); }} /> + ) : ( diff --git a/utils/StaticScriptLoader.js b/utils/StaticScriptLoader.js index 1c97ac1..36ac329 100644 --- a/utils/StaticScriptLoader.js +++ b/utils/StaticScriptLoader.js @@ -13,6 +13,7 @@ const loadStaticFile = async (asset) => { class Loader { scripts = { + NativeAudioPlayer: '', NativeVideoPlayer: '', NativeShell: '', ExpoRouterShim: '' @@ -20,6 +21,7 @@ class Loader { async load() { // Load NativeShell plugins + this.scripts.NativeAudioPlayer = await loadStaticFile(require('../assets/js/plugins/NativeAudioPlayer.staticjs')); this.scripts.NativeVideoPlayer = await loadStaticFile(require('../assets/js/plugins/NativeVideoPlayer.staticjs')); // Load the NativeShell this.scripts.NativeShell = await loadStaticFile(require('../assets/js/NativeShell.staticjs'));