Add native audio player

This commit is contained in:
Bill Thornton 2021-11-09 14:37:50 -05:00
parent 4986e0b23c
commit 0c09b97962
6 changed files with 260 additions and 0 deletions

View File

@ -119,6 +119,7 @@ window.NativeShell = {
return [];
}
return [
// 'NativeAudioPlayer',
'NativeVideoPlayer'
];
},

View File

@ -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;

72
components/AudioPlayer.js Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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);
}}
/>
<AudioPlayer/>
<VideoPlayer/>
</>
) : (

View File

@ -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'));