diff --git a/apps/native-component-list/src/screens/Audio/AudioPlaylistScreen.tsx b/apps/native-component-list/src/screens/Audio/AudioPlaylistScreen.tsx
new file mode 100644
index 00000000000000..ce11068c4b37ee
--- /dev/null
+++ b/apps/native-component-list/src/screens/Audio/AudioPlaylistScreen.tsx
@@ -0,0 +1,328 @@
+import { AudioSource, useAudioPlaylist, useAudioPlaylistStatus } from 'expo-audio';
+import React, { useState } from 'react';
+import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
+
+import HeadingText from '../../components/HeadingText';
+import Colors from '../../constants/Colors';
+
+const INITIAL_SOURCES: AudioSource[] = [
+ require('../../../assets/sounds/polonez.mp3'),
+ {
+ uri: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
+ name: 'Song 1',
+ },
+ {
+ uri: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
+ name: 'Song 2',
+ },
+];
+
+const ADDITIONAL_SOURCES: AudioSource[] = [
+ { uri: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-4.mp3', name: 'Song 4' },
+ { uri: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-5.mp3', name: 'Song 5' },
+];
+
+function getTrackName(uri: string): string {
+ const filename = uri.split('/').pop()?.split('?')[0] ?? 'Unknown';
+ return filename.replace(/\.[^/.]+$/, '');
+}
+
+function formatTime(seconds: number): string {
+ if (!isFinite(seconds) || isNaN(seconds)) return '0:00';
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
+function Button({
+ title,
+ onPress,
+ disabled,
+}: {
+ title: string;
+ onPress: () => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ {title}
+
+ );
+}
+
+export default function AudioPlaylistScreen() {
+ const [addTrackIndex, setAddTrackIndex] = useState(0);
+
+ const playlist = useAudioPlaylist({
+ sources: INITIAL_SOURCES,
+ loop: 'none',
+ });
+
+ const status = useAudioPlaylistStatus(playlist);
+
+ const sources = playlist.sources;
+ const currentSource = sources[status.currentIndex];
+ const currentTrackName = currentSource
+ ? (currentSource.name ?? getTrackName(currentSource.uri ?? ''))
+ : 'No track';
+
+ const handleAddTrack = () => {
+ const sourceToAdd = ADDITIONAL_SOURCES[addTrackIndex % ADDITIONAL_SOURCES.length];
+ playlist.add(sourceToAdd);
+ setAddTrackIndex((prev) => prev + 1);
+ };
+
+ const handleClear = () => {
+ playlist.clear();
+ };
+
+ return (
+
+
+ {currentTrackName}
+
+ Track {status.currentIndex + 1} of {status.trackCount}
+
+
+
+
+
+ 0 ? (status.currentTime / status.duration) * 100 : 0}%`,
+ },
+ ]}
+ />
+
+
+ {formatTime(status.currentTime)}
+ {formatTime(status.duration)}
+
+
+
+
+
+
+ Playlist ({sources.length} tracks)
+
+ {sources.length === 0 ? (
+ Playlist is empty
+ ) : (
+ sources.map((source, index) => (
+ playlist.skipTo(index)}>
+
+ {index + 1}. {source.name ?? getTrackName(source.uri ?? '')}
+
+ {index === status.currentIndex && Now Playing}
+
+ ))
+ )}
+
+
+ Loop Mode
+
+ (playlist.loop = 'none')}
+ disabled={status.loop === 'none'}
+ />
+ (playlist.loop = 'single')}
+ disabled={status.loop === 'single'}
+ />
+ (playlist.loop = 'all')}
+ disabled={status.loop === 'all'}
+ />
+
+
+ Status
+
+
+ Playing
+ {`${status.playing}`}
+
+
+ Buffering
+ {`${status.isBuffering}`}
+
+
+ Loaded
+ {`${status.isLoaded}`}
+
+
+ Loop
+ {status.loop}
+
+
+ Volume
+ {(status.volume * 100).toFixed(0)}%
+
+
+
+ Playlist Management
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ content: {
+ padding: 10,
+ gap: 10,
+ },
+ trackInfo: {
+ alignItems: 'center',
+ marginBottom: 20,
+ },
+ trackTitle: {
+ fontSize: 18,
+ fontWeight: '600',
+ marginBottom: 4,
+ },
+ trackIndex: {
+ fontSize: 14,
+ color: Colors.secondaryText,
+ },
+ progressContainer: {
+ marginBottom: 20,
+ },
+ progressBar: {
+ height: 4,
+ backgroundColor: Colors.border,
+ borderRadius: 2,
+ overflow: 'hidden',
+ },
+ progressFill: {
+ height: '100%',
+ backgroundColor: Colors.tintColor,
+ },
+ timeContainer: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginTop: 4,
+ },
+ timeText: {
+ fontSize: 12,
+ color: Colors.secondaryText,
+ },
+ controls: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ gap: 12,
+ marginBottom: 20,
+ },
+ button: {
+ paddingHorizontal: 20,
+ paddingVertical: 10,
+ backgroundColor: Colors.tintColor,
+ borderRadius: 8,
+ },
+ buttonDisabled: {
+ backgroundColor: Colors.disabled,
+ },
+ buttonText: {
+ color: '#fff',
+ fontWeight: '600',
+ },
+ buttonTextDisabled: {
+ color: Colors.secondaryText,
+ },
+ playlistTracks: {
+ marginBottom: 20,
+ },
+ emptyPlaylist: {
+ textAlign: 'center',
+ color: Colors.disabled,
+ paddingVertical: 20,
+ },
+ trackItem: {
+ paddingVertical: 12,
+ paddingHorizontal: 10,
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.border,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ trackItemActive: {
+ backgroundColor: Colors.greyBackground,
+ },
+ trackItemText: {
+ fontSize: 16,
+ },
+ trackItemTextActive: {
+ fontWeight: '600',
+ color: Colors.tintColor,
+ },
+ nowPlaying: {
+ fontSize: 12,
+ color: Colors.tintColor,
+ },
+ loopControls: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ gap: 12,
+ marginBottom: 20,
+ },
+ statusContainer: {
+ backgroundColor: Colors.greyBackground,
+ borderRadius: 8,
+ marginBottom: 20,
+ overflow: 'hidden',
+ },
+ statusRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ paddingVertical: 10,
+ paddingHorizontal: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.border,
+ },
+ statusLabel: {
+ fontSize: 14,
+ color: Colors.secondaryText,
+ },
+ statusValue: {
+ fontSize: 14,
+ fontWeight: '600',
+ },
+});
diff --git a/apps/native-component-list/src/screens/Audio/AudioScreen.tsx b/apps/native-component-list/src/screens/Audio/AudioScreen.tsx
index 42c3f30307cbce..fcba58c5858a75 100644
--- a/apps/native-component-list/src/screens/Audio/AudioScreen.tsx
+++ b/apps/native-component-list/src/screens/Audio/AudioScreen.tsx
@@ -10,6 +10,14 @@ export const AudioScreens = [
return optionalRequire(() => require('./AudioPlayerScreen'));
},
},
+ {
+ name: 'Expo Audio Playlist',
+ route: 'audio/expo-audio-playlist',
+ options: {},
+ getComponent() {
+ return optionalRequire(() => require('./AudioPlaylistScreen'));
+ },
+ },
{
name: 'Expo Audio Recording',
route: 'audio/expo-audio-recording',
diff --git a/apps/native-component-list/src/screens/Audio/Recorder.tsx b/apps/native-component-list/src/screens/Audio/Recorder.tsx
index 6a3199cd56f7d7..051b5aa971d0ec 100644
--- a/apps/native-component-list/src/screens/Audio/Recorder.tsx
+++ b/apps/native-component-list/src/screens/Audio/Recorder.tsx
@@ -31,7 +31,7 @@ type RecorderProps = {
export default function Recorder({ onDone, style }: RecorderProps) {
const [state, setState] = React.useState({
- id: 0,
+ id: 'initial',
hasError: false,
error: null,
isFinished: false,
diff --git a/apps/native-component-list/src/screens/UI/TextScreen.ios.tsx b/apps/native-component-list/src/screens/UI/TextScreen.ios.tsx
index 38d34bf15c5a6e..dc096aca96c812 100644
--- a/apps/native-component-list/src/screens/UI/TextScreen.ios.tsx
+++ b/apps/native-component-list/src/screens/UI/TextScreen.ios.tsx
@@ -39,6 +39,23 @@ export default function TextScreen() {
Hello world!
+
+
+ Primary{' '}
+
+ Secondary
+
+
+
+ Secondary
+
+
+ Tertiary
+
+
+ Quaternary
+
+
Inter Bold Font
Inter Medium Font
diff --git a/apps/router-e2e/__e2e__/link-preview/app/zoom-dest-contain.tsx b/apps/router-e2e/__e2e__/link-preview/app/zoom-dest-contain.tsx
index c7b401361d647d..8a4e8ee5c96ce8 100644
--- a/apps/router-e2e/__e2e__/link-preview/app/zoom-dest-contain.tsx
+++ b/apps/router-e2e/__e2e__/link-preview/app/zoom-dest-contain.tsx
@@ -1,9 +1,27 @@
-import { Link } from 'expo-router';
-import { Image, View } from 'react-native';
+import { Link, Stack } from 'expo-router';
+import { useState } from 'react';
+import { Button, Image, View } from 'react-native';
export default function ZoomDestScreen() {
+ const [gesturesEnabled, setGesturesEnabled] = useState(true);
return (
+
+
+ setGesturesEnabled((enabled) => !enabled)}
+ />
+
Unit)?
+
+ val player: Player
+ val appContext: AppContext?
+
+ val currentTime: Double get() = player.currentPosition / 1000.0
+ val duration: Double get() = if (player.duration != C.TIME_UNSET) player.duration / 1000.0 else 0.0
+ val isPlaying: Boolean get() = player.isPlaying
+ val volume: Float get() = player.volume
+
+ fun play() {
+ player.play()
+ }
+
+ fun pause() {
+ player.pause()
+ }
+
+ fun seekTo(seconds: Double) {
+ player.seekTo((seconds * 1000L).toLong())
+ }
+
+ fun setVolume(volume: Float?) {
+ appContext?.mainQueue?.launch {
+ val boundedVolume = volume?.coerceIn(0f, 1f) ?: 1f
+ if (isMuted) {
+ if (boundedVolume > 0f) {
+ previousVolume = boundedVolume
+ }
+ player.volume = 0f
+ } else {
+ previousVolume = boundedVolume
+ player.volume = boundedVolume
+ }
+ }
+ }
+
+ fun setPlaybackRate(rate: Float)
+ fun currentStatus(): Map
+}
diff --git a/packages/expo-audio/CHANGELOG.md b/packages/expo-audio/CHANGELOG.md
index ee0aad1562a953..02ca59e5bb26e8 100644
--- a/packages/expo-audio/CHANGELOG.md
+++ b/packages/expo-audio/CHANGELOG.md
@@ -20,6 +20,7 @@
- [Web] Add support for media controls. ([#43150](https://github.com/expo/expo/pull/43150) by [@alanjhughes](https://github.com/alanjhughes))
- [Web] Add support for selecting recording inputs. ([#43151](https://github.com/expo/expo/pull/43151) by [@alanjhughes](https://github.com/alanjhughes))
- [Web] Add support for recording metering. ([#43152](https://github.com/expo/expo/pull/43152) by [@alanjhughes](https://github.com/alanjhughes))
+- [Web] Enable `setIsAudioActiveAsync`. ([#43142](https://github.com/expo/expo/pull/43142) by [@alanjhughes](https://github.com/alanjhughes))
### 🐛 Bug fixes
diff --git a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioModule.kt b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioModule.kt
index 722063aee9df84..93fc8b6fcbf275 100644
--- a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioModule.kt
+++ b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioModule.kt
@@ -17,7 +17,6 @@ import androidx.media3.common.C.CONTENT_TYPE_HLS
import androidx.media3.common.C.CONTENT_TYPE_OTHER
import androidx.media3.common.C.CONTENT_TYPE_SS
import androidx.media3.common.MediaItem
-import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.util.Util
import androidx.media3.datasource.DataSource
@@ -40,7 +39,6 @@ import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import java.io.File
import java.util.concurrent.ConcurrentHashMap
-import kotlin.math.min
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class AudioModule : Module() {
@@ -51,6 +49,7 @@ class AudioModule : Module() {
private val players = ConcurrentHashMap()
private val recorders = ConcurrentHashMap()
+ private val playlists = ConcurrentHashMap()
private var shouldPlayInBackground = false
private var audioEnabled = true
private var shouldRouteThroughEarpiece = false
@@ -58,40 +57,41 @@ class AudioModule : Module() {
private var interruptionMode: InterruptionMode? = null
private var allowsBackgroundRecording = false
+ private val allPlayables: Sequence
+ get() = players.values.asSequence() + playlists.values.asSequence()
+
private var audioFocusRequest: AudioFocusRequest? = null
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
appContext.mainQueue.launch {
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS -> {
focusAcquired = false
- players.values.forEach { player ->
- player.ref.pause()
- }
+ allPlayables.forEach { it.pause() }
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
focusAcquired = false
- players.values.forEach { player ->
- if (player.ref.isPlaying) {
- player.isPaused = true
- player.ref.pause()
+ allPlayables.forEach { playable ->
+ if (playable.isPlaying) {
+ playable.isPaused = true
+ playable.pause()
}
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
if (interruptionMode == InterruptionMode.DUCK_OTHERS) {
- players.values.forEach { player ->
- if (player.previousVolume != player.ref.volume) {
- player.previousVolume = player.ref.volume
+ allPlayables.forEach { playable ->
+ if (playable.previousVolume != playable.volume) {
+ playable.previousVolume = playable.volume
}
- player.ref.volume = player.previousVolume * 0.5f
+ playable.setVolume(playable.previousVolume * 0.5f)
}
} else {
- players.values.forEach { player ->
- if (player.ref.isPlaying) {
- player.isPaused = true
- player.ref.pause()
+ allPlayables.forEach { playable ->
+ if (playable.isPlaying) {
+ playable.isPaused = true
+ playable.pause()
}
}
}
@@ -99,11 +99,11 @@ class AudioModule : Module() {
AudioManager.AUDIOFOCUS_GAIN -> {
focusAcquired = true
- players.values.forEach { player ->
- player.setVolume(player.previousVolume)
- if (player.isPaused) {
- player.isPaused = false
- player.ref.play()
+ allPlayables.forEach { playable ->
+ playable.setVolume(playable.previousVolume)
+ if (playable.isPaused) {
+ playable.isPaused = false
+ playable.play()
}
}
}
@@ -112,7 +112,7 @@ class AudioModule : Module() {
}
private fun shouldReleaseFocus(): Boolean {
- return players.values.none { it.ref.isPlaying }
+ return allPlayables.none { it.isPlaying }
}
private fun requestAudioFocus() {
@@ -197,9 +197,9 @@ class AudioModule : Module() {
if (!enabled) {
releaseAudioFocus()
runOnMain {
- players.values.forEach {
- if (it.ref.isPlaying) {
- it.ref.pause()
+ allPlayables.forEach {
+ if (it.isPlaying) {
+ it.pause()
}
}
}
@@ -221,10 +221,10 @@ class AudioModule : Module() {
OnActivityEntersBackground {
if (!shouldPlayInBackground) {
releaseAudioFocus()
- players.values.forEach { player ->
- if (player.ref.isPlaying) {
- player.isPaused = true
- player.ref.pause()
+ allPlayables.forEach { playable ->
+ if (playable.isPlaying) {
+ playable.isPaused = true
+ playable.pause()
}
}
}
@@ -239,15 +239,14 @@ class AudioModule : Module() {
OnActivityEntersForeground {
if (!shouldPlayInBackground) {
- val hasPlayersToResume = players.values.any { it.isPaused }
- if (hasPlayersToResume) {
+ if (allPlayables.any { it.isPaused }) {
requestAudioFocus()
}
- players.values.forEach { player ->
- if (player.isPaused) {
- player.isPaused = false
- player.ref.play()
+ allPlayables.forEach { playable ->
+ if (playable.isPaused) {
+ playable.isPaused = false
+ playable.play()
}
}
}
@@ -270,6 +269,10 @@ class AudioModule : Module() {
it.ref.stop()
}
+ playlists.values.forEach {
+ it.ref.stop()
+ }
+
recorders.values.forEach {
it.stopRecording()
}
@@ -455,9 +458,7 @@ class AudioModule : Module() {
Function("setPlaybackRate") { player: AudioPlayer, rate: Float ->
appContext.mainQueue.launch {
- val playbackRate = if (rate <= 0) 0.1f else min(rate, 2.0f)
- val pitch = if (player.preservesPitch) 1f else playbackRate
- player.ref.playbackParameters = PlaybackParameters(playbackRate, pitch)
+ player.setPlaybackRate(rate)
}
}
@@ -552,6 +553,200 @@ class AudioModule : Module() {
}
}
}
+
+ Class(AudioPlaylist::class) {
+ Constructor { sources: List, updateInterval: Double, loop: LoopMode ->
+ runOnMain {
+ val playlist = AudioPlaylist(
+ context,
+ appContext,
+ sources,
+ updateInterval,
+ DefaultDataSource.Factory(context)
+ )
+ playlist.loopMode = loop
+ playlist.setMediaItemCreator { source ->
+ source.uri?.let { uriString ->
+ val uri = uriString.toUri()
+ when {
+ isRawResource(uri) -> {
+ val file = getResourceName(uri, uriString)
+ MediaItem.fromUri(getRawResourceURI(file))
+ }
+ else -> MediaItem.fromUri(uri)
+ }
+ }
+ }
+ playlist.loadInitialPlaylist()
+ playlist.onPlaybackStateChange = { isPlaying ->
+ if (!isPlaying && shouldReleaseFocus()) {
+ releaseAudioFocus()
+ }
+ }
+ playlists[playlist.id] = playlist
+ playlist
+ }
+ }
+
+ Property("id") { playlist ->
+ playlist.id
+ }
+
+ Property("currentIndex") { playlist ->
+ runOnMain {
+ playlist.currentTrackIndex
+ }
+ }
+
+ Property("trackCount") { playlist ->
+ runOnMain {
+ playlist.trackCount
+ }
+ }
+
+ Property("sources") { playlist ->
+ playlist.getSources()
+ }
+
+ Property("playing") { playlist ->
+ runOnMain {
+ playlist.ref.isPlaying
+ }
+ }
+
+ Property("muted") { playlist ->
+ playlist.isMuted
+ }.set { playlist, muted: Boolean? ->
+ val newMuted = muted ?: false
+ playlist.isMuted = newMuted
+ playlist.setVolume(if (newMuted) 0f else playlist.previousVolume)
+ }
+
+ Property("isLoaded") { playlist ->
+ runOnMain {
+ playlist.ref.playbackState == Player.STATE_READY
+ }
+ }
+
+ Property("isBuffering") { playlist ->
+ runOnMain {
+ playlist.ref.playbackState == Player.STATE_BUFFERING
+ }
+ }
+
+ Property("currentTime") { playlist ->
+ runOnMain {
+ playlist.currentTime
+ }
+ }
+
+ Property("duration") { playlist ->
+ runOnMain {
+ playlist.duration
+ }
+ }
+
+ Property("volume") { playlist ->
+ runOnMain {
+ playlist.ref.volume
+ }
+ }.set { playlist, volume: Float? ->
+ playlist.setVolume(volume)
+ }
+
+ Property("playbackRate") { playlist ->
+ runOnMain {
+ playlist.ref.playbackParameters.speed
+ }
+ }.set { playlist, rate: Float ->
+ appContext.mainQueue.launch {
+ playlist.setPlaybackRate(rate)
+ }
+ }
+
+ Property("loop") { playlist ->
+ playlist.loopMode.value
+ }.set { playlist, mode: LoopMode ->
+ runOnMain {
+ playlist.loopMode = mode
+ }
+ }
+
+ Property("currentStatus") { playlist ->
+ runOnMain {
+ playlist.currentStatus()
+ }
+ }
+
+ Function("play") { playlist: AudioPlaylist ->
+ if (!audioEnabled) {
+ Log.e(TAG, "Audio has been disabled. Re-enable to start playing")
+ return@Function
+ }
+ runOnMain {
+ if (!focusAcquired) {
+ requestAudioFocus()
+ }
+ playlist.ref.play()
+ }
+ }
+
+ Function("pause") { playlist: AudioPlaylist ->
+ runOnMain {
+ playlist.ref.pause()
+ }
+ }
+
+ Function("next") { playlist: AudioPlaylist ->
+ runOnMain {
+ playlist.next()
+ }
+ }
+
+ Function("previous") { playlist: AudioPlaylist ->
+ runOnMain {
+ playlist.previous()
+ }
+ }
+
+ Function("skipTo") { playlist: AudioPlaylist, index: Int ->
+ runOnMain {
+ playlist.skipTo(index)
+ }
+ }
+
+ AsyncFunction("seekTo") { playlist: AudioPlaylist, seconds: Double ->
+ playlist.seekTo(seconds)
+ }.runOnQueue(Queues.MAIN)
+
+ Function("add") { playlist: AudioPlaylist, source: AudioSource ->
+ runOnMain {
+ playlist.add(source)
+ }
+ }
+
+ Function("insert") { playlist: AudioPlaylist, source: AudioSource, index: Int ->
+ runOnMain {
+ playlist.insert(source, index)
+ }
+ }
+
+ Function("remove") { playlist: AudioPlaylist, index: Int ->
+ runOnMain {
+ playlist.remove(index)
+ }
+ }
+
+ Function("clear") { playlist: AudioPlaylist ->
+ runOnMain {
+ playlist.clear()
+ }
+ }
+
+ Function("destroy") { playlist: AudioPlaylist ->
+ playlists.remove(playlist.id)
+ }
+ }
}
private fun createMediaItem(source: AudioSource?): MediaSource? = source?.uri?.let { uriString ->
diff --git a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioPlayer.kt b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioPlayer.kt
index 38a1d95bbbebf6..878750c8b25672 100644
--- a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioPlayer.kt
+++ b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioPlayer.kt
@@ -7,8 +7,8 @@ import android.media.audiofx.Visualizer
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.media3.common.AudioAttributes
-import androidx.media3.common.C
import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
@@ -52,13 +52,14 @@ class AudioPlayer(
.setSeekBackIncrementMs(SEEK_JUMP_INTERVAL_MS)
.build(),
appContext
-) {
- val id = UUID.randomUUID().toString()
+),
+ Playable {
+ override val id = UUID.randomUUID().toString()
var preservesPitch = true
- var isPaused = false
- var isMuted = false
- var previousVolume = 1f
- var onPlaybackStateChange: ((Boolean) -> Unit)? = null
+ override var isPaused = false
+ override var isMuted = false
+ override var previousVolume = 1f
+ override var onPlaybackStateChange: ((Boolean) -> Unit)? = null
// Lock screen controls
var isActiveForLockScreen = false
@@ -68,6 +69,7 @@ class AudioPlayer(
val serviceConnection = AudioPlaybackServiceConnection(WeakReference(this), appContext)
private var playerScope = CoroutineScope(Dispatchers.Main)
+ private var playerListener: Player.Listener? = null
private var samplingEnabled = false
private var visualizer: Visualizer? = null
private var playing = false
@@ -80,8 +82,7 @@ class AudioPlayer(
private var previousPlaybackState = Player.STATE_IDLE
private var intendedPlayingState = false
- val currentTime get() = ref.currentPosition / 1000f
- val duration get() = if (ref.duration != C.TIME_UNSET) ref.duration / 1000f else 0f
+ override val player get() = ref
init {
addPlayerListeners()
@@ -90,19 +91,6 @@ class AudioPlayer(
}
}
- fun setVolume(volume: Float?) = appContext?.mainQueue?.launch {
- val boundedVolume = volume?.coerceIn(0f, 1f) ?: 1f
- if (isMuted) {
- if (boundedVolume > 0f) {
- previousVolume = boundedVolume
- }
- ref.volume = 0f
- } else {
- previousVolume = boundedVolume
- ref.volume = boundedVolume
- }
- }
-
fun setMediaSource(source: MediaSource) {
previousPlaybackState = Player.STATE_IDLE
ref.setMediaSource(source)
@@ -176,60 +164,64 @@ class AudioPlayer(
.launchIn(playerScope)
}
- private fun addPlayerListeners() = ref.addListener(object : Player.Listener {
- override fun onIsPlayingChanged(isPlaying: Boolean) {
- playing = isPlaying
- onPlaybackStateChange?.invoke(isPlaying)
+ private fun addPlayerListeners() {
+ val listener = object : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ playing = isPlaying
+ onPlaybackStateChange?.invoke(isPlaying)
- val isTransient = !isPlaying &&
- (ref.playbackState == Player.STATE_ENDED || ref.playbackState == Player.STATE_BUFFERING)
- if (!isTransient) {
- intendedPlayingState = isPlaying
+ val isTransient = !isPlaying &&
+ (ref.playbackState == Player.STATE_ENDED || ref.playbackState == Player.STATE_BUFFERING)
+ if (!isTransient) {
+ intendedPlayingState = isPlaying
+ }
+
+ if (isTransient) {
+ return
+ }
+ sendPlayerUpdate(mapOf("playing" to isPlaying))
}
- if (isTransient) {
- return
+ override fun onIsLoadingChanged(isLoading: Boolean) {
+ sendPlayerUpdate()
}
- sendPlayerUpdate(mapOf("playing" to isPlaying))
- }
- override fun onIsLoadingChanged(isLoading: Boolean) {
- sendPlayerUpdate()
- }
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ val justFinished = playbackState == Player.STATE_ENDED &&
+ previousPlaybackState != Player.STATE_ENDED
+ previousPlaybackState = playbackState
- override fun onPlaybackStateChanged(playbackState: Int) {
- val justFinished = playbackState == Player.STATE_ENDED &&
- previousPlaybackState != Player.STATE_ENDED
- previousPlaybackState = playbackState
+ if (justFinished) {
+ intendedPlayingState = false
+ }
- if (justFinished) {
- intendedPlayingState = false
+ val updateMap = mutableMapOf(
+ "playbackState" to playbackStateToString(playbackState)
+ )
+ if (justFinished) {
+ updateMap["didJustFinish"] = true
+ updateMap["playing"] = false
+ }
+ sendPlayerUpdate(updateMap)
}
- val updateMap = mutableMapOf(
- "playbackState" to playbackStateToString(playbackState)
- )
- if (justFinished) {
- updateMap["didJustFinish"] = true
- updateMap["playing"] = false
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
+ sendPlayerUpdate()
}
- sendPlayerUpdate(updateMap)
- }
-
- override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
- sendPlayerUpdate()
- }
- override fun onPositionDiscontinuity(
- oldPosition: Player.PositionInfo,
- newPosition: Player.PositionInfo,
- reason: Int
- ) {
- if (reason == Player.DISCONTINUITY_REASON_SEEK) {
- sendPlayerUpdate(mapOf("currentTime" to (newPosition.positionMs / 1000f)))
+ override fun onPositionDiscontinuity(
+ oldPosition: Player.PositionInfo,
+ newPosition: Player.PositionInfo,
+ reason: Int
+ ) {
+ if (reason == Player.DISCONTINUITY_REASON_SEEK) {
+ sendPlayerUpdate(mapOf("currentTime" to (newPosition.positionMs / 1000f)))
+ }
}
}
- })
+ playerListener = listener
+ ref.addListener(listener)
+ }
fun setSamplingEnabled(enabled: Boolean) {
appContext?.reactContext?.let {
@@ -248,8 +240,10 @@ class AudioPlayer(
}
}
- fun seekTo(seekTime: Double) {
- ref.seekTo((seekTime * 1000L).toLong())
+ override fun setPlaybackRate(rate: Float) {
+ val playbackRate = rate.coerceIn(0.1f, 2.0f)
+ val pitch = if (preservesPitch) 1f else playbackRate
+ ref.playbackParameters = PlaybackParameters(playbackRate, pitch)
}
private fun extractAmplitudes(chunk: ByteArray): List = chunk.map { byte ->
@@ -257,7 +251,7 @@ class AudioPlayer(
((unsignedByte - 128).toDouble() / 128.0).toFloat()
}
- fun currentStatus(): Map {
+ override fun currentStatus(): Map {
val isMuted = ref.volume == 0f
val isLooping = ref.repeatMode == Player.REPEAT_MODE_ONE
val isLoaded = ref.playbackState == Player.STATE_READY
@@ -354,6 +348,7 @@ class AudioPlayer(
serviceConnection.playbackServiceBinder?.service?.unregisterPlayer()
}
serviceConnection.unbind()
+ playerListener?.let { ref.removeListener(it) }
playerScope.cancel()
visualizer?.release()
ref.release()
diff --git a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioPlaylist.kt b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioPlaylist.kt
new file mode 100644
index 00000000000000..19bf117738938b
--- /dev/null
+++ b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioPlaylist.kt
@@ -0,0 +1,347 @@
+package expo.modules.audio
+
+import android.content.Context
+import androidx.media3.common.AudioAttributes
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import androidx.media3.datasource.DataSource
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.sharedobjects.SharedRef
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.launch
+import java.util.UUID
+
+private const val PLAYLIST_STATUS_UPDATE = "playlistStatusUpdate"
+private const val TRACK_CHANGED = "trackChanged"
+
+@UnstableApi
+class AudioPlaylist(
+ context: Context,
+ appContext: AppContext,
+ initialSources: List,
+ private val updateInterval: Double,
+ dataSourceFactory: DataSource.Factory
+) : SharedRef(
+ ExoPlayer.Builder(context)
+ .setLooper(context.mainLooper)
+ .setAudioAttributes(AudioAttributes.DEFAULT, false)
+ .setMediaSourceFactory(DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory))
+ .build(),
+ appContext
+),
+ Playable {
+ override val id: String = UUID.randomUUID().toString()
+
+ private var sources: MutableList = initialSources.toMutableList()
+
+ override var isPaused = false
+ override var isMuted = false
+ override var previousVolume = 1f
+ override var onPlaybackStateChange: ((Boolean) -> Unit)? = null
+
+ private var playerScope = CoroutineScope(Dispatchers.Main)
+ private var playerListener: Player.Listener? = null
+ private var playing = false
+ private var updateJob: Job? = null
+ private var previousPlaybackState = Player.STATE_IDLE
+ private var intendedPlayingState = false
+ private var currentRate = 1f
+ private var previousMediaItemIndex = 0
+
+ val currentTrackIndex get() = ref.currentMediaItemIndex
+ override val player get() = ref
+ val trackCount get() = ref.mediaItemCount
+
+ private var createMediaItemForSource: ((AudioSource) -> MediaItem?)? = null
+
+ init {
+ addPlayerListeners()
+ startUpdating()
+ }
+
+ fun setMediaItemCreator(creator: (AudioSource) -> MediaItem?) {
+ this.createMediaItemForSource = creator
+ }
+
+ fun loadInitialPlaylist() {
+ val validSources = mutableListOf()
+ sources.forEach { source ->
+ createMediaItemForSource?.invoke(source)?.let { mediaItem ->
+ validSources.add(source)
+ ref.addMediaItem(mediaItem)
+ }
+ }
+ sources = validSources
+ ref.prepare()
+ }
+
+ var loopMode: LoopMode
+ get() = when (ref.repeatMode) {
+ Player.REPEAT_MODE_ONE -> LoopMode.SINGLE
+ Player.REPEAT_MODE_ALL -> LoopMode.ALL
+ else -> LoopMode.NONE
+ }
+ set(value) {
+ ref.repeatMode = when (value) {
+ LoopMode.SINGLE -> Player.REPEAT_MODE_ONE
+ LoopMode.ALL -> Player.REPEAT_MODE_ALL
+ LoopMode.NONE -> Player.REPEAT_MODE_OFF
+ }
+ playerScope.launch {
+ sendStatusUpdate(mapOf("loop" to value.value))
+ }
+ }
+
+ fun getSources(): List {
+ return sources.toList()
+ }
+
+ private fun addPlayerListeners() {
+ val listener = object : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ playing = isPlaying
+ onPlaybackStateChange?.invoke(isPlaying)
+
+ val isTransient = !isPlaying &&
+ (ref.playbackState == Player.STATE_ENDED || ref.playbackState == Player.STATE_BUFFERING)
+ if (!isTransient) {
+ intendedPlayingState = isPlaying
+ }
+
+ if (isTransient) {
+ return
+ }
+ playerScope.launch {
+ sendStatusUpdate(mapOf("playing" to isPlaying))
+ }
+ }
+
+ override fun onIsLoadingChanged(isLoading: Boolean) {
+ playerScope.launch {
+ sendStatusUpdate(mapOf("isLoaded" to (ref.playbackState == Player.STATE_READY)))
+ }
+ }
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ val justFinished = playbackState == Player.STATE_ENDED &&
+ previousPlaybackState != Player.STATE_ENDED
+ previousPlaybackState = playbackState
+
+ if (justFinished) {
+ intendedPlayingState = false
+ }
+
+ playerScope.launch {
+ val updateMap = mutableMapOf()
+ if (justFinished) {
+ updateMap["didJustFinish"] = true
+ updateMap["playing"] = false
+ }
+ sendStatusUpdate(updateMap)
+ }
+ }
+
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
+ val currentIndex = ref.currentMediaItemIndex
+ if (currentIndex != previousMediaItemIndex) {
+ emitTrackChanged(previousMediaItemIndex, currentIndex)
+ previousMediaItemIndex = currentIndex
+ }
+ playerScope.launch {
+ sendStatusUpdate()
+ }
+ }
+
+ override fun onPositionDiscontinuity(
+ oldPosition: Player.PositionInfo,
+ newPosition: Player.PositionInfo,
+ reason: Int
+ ) {
+ if (reason == Player.DISCONTINUITY_REASON_SEEK) {
+ playerScope.launch {
+ sendStatusUpdate(mapOf("currentTime" to (newPosition.positionMs / 1000.0)))
+ }
+ }
+ }
+
+ override fun onPlayerError(error: PlaybackException) {
+ playerScope.launch {
+ sendStatusUpdate(
+ mapOf(
+ "error" to mapOf(
+ "message" to error.message,
+ "code" to error.errorCode
+ )
+ )
+ )
+ }
+ }
+ }
+ playerListener = listener
+ ref.addListener(listener)
+ }
+
+ fun next() {
+ if (ref.hasNextMediaItem()) {
+ ref.seekToNextMediaItem()
+ } else if (loopMode == LoopMode.ALL && trackCount > 0) {
+ ref.seekToDefaultPosition(0)
+ }
+ }
+
+ fun previous() {
+ if (ref.hasPreviousMediaItem()) {
+ ref.seekToPreviousMediaItem()
+ } else if (loopMode == LoopMode.ALL && trackCount > 0) {
+ ref.seekToDefaultPosition(trackCount - 1)
+ }
+ }
+
+ fun skipTo(index: Int) {
+ if (index !in 0.. {
+ val isMuted = ref.volume == 0f
+ val isLoaded = ref.playbackState == Player.STATE_READY
+ val isBuffering = ref.playbackState == Player.STATE_BUFFERING
+ val playingStatus = if (isBuffering) intendedPlayingState else ref.isPlaying
+
+ return mapOf(
+ "id" to id,
+ "currentIndex" to currentTrackIndex,
+ "trackCount" to trackCount,
+ "currentTime" to currentTime,
+ "duration" to duration,
+ "playing" to playingStatus,
+ "isBuffering" to isBuffering,
+ "isLoaded" to if (ref.playbackState == Player.STATE_ENDED) true else isLoaded,
+ "playbackRate" to if (ref.isPlaying) ref.playbackParameters.speed else currentRate,
+ "muted" to isMuted,
+ "volume" to ref.volume,
+ "loop" to loopMode.value,
+ "didJustFinish" to false
+ )
+ }
+
+ private fun startUpdating() {
+ updateJob?.cancel()
+ updateJob = flow {
+ while (true) {
+ emit(Unit)
+ delay(updateInterval.toLong())
+ }
+ }
+ .onStart {
+ sendStatusUpdate()
+ }
+ .onEach {
+ if (playing) {
+ sendStatusUpdate()
+ }
+ }
+ .launchIn(playerScope)
+ }
+
+ private fun sendStatusUpdate(map: Map? = null) {
+ val data = currentStatus()
+ val body = map?.let { data + it } ?: data
+ emit(PLAYLIST_STATUS_UPDATE, body)
+ }
+
+ private fun emitTrackChanged(previousIndex: Int, currentIndex: Int) {
+ emit(
+ TRACK_CHANGED,
+ mapOf(
+ "previousIndex" to previousIndex,
+ "currentIndex" to currentIndex
+ )
+ )
+ playerScope.launch {
+ sendStatusUpdate()
+ }
+ }
+
+ private fun release() {
+ playerListener?.let { ref.removeListener(it) }
+ playerScope.cancel()
+ ref.release()
+ }
+
+ override fun sharedObjectDidRelease() {
+ super.sharedObjectDidRelease()
+ // Run on GlobalScope (not appContext.mainQueue) so that reloading doesn't cancel the release process
+ GlobalScope.launch(Dispatchers.Main) {
+ release()
+ }
+ }
+}
diff --git a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecords.kt b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecords.kt
index 092af43b1c1120..1d9541e2ff3eab 100644
--- a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecords.kt
+++ b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecords.kt
@@ -9,9 +9,16 @@ import java.net.URL
class AudioSource(
@Field val uri: String?,
- @Field val headers: Map?
+ @Field val headers: Map?,
+ @Field val name: String? = null
) : Record
+enum class LoopMode(val value: String) : Enumerable {
+ NONE("none"),
+ SINGLE("single"),
+ ALL("all")
+}
+
class AudioMode(
@Field val shouldPlayInBackground: Boolean = false,
@Field val shouldRouteThroughEarpiece: Boolean?,
diff --git a/packages/expo-audio/android/src/main/java/expo/modules/audio/Playable.kt b/packages/expo-audio/android/src/main/java/expo/modules/audio/Playable.kt
new file mode 100644
index 00000000000000..06c28423eafbe3
--- /dev/null
+++ b/packages/expo-audio/android/src/main/java/expo/modules/audio/Playable.kt
@@ -0,0 +1,56 @@
+package expo.modules.audio
+
+import androidx.media3.common.C
+import androidx.media3.common.Player
+import expo.modules.kotlin.AppContext
+import kotlinx.coroutines.launch
+
+/**
+ * Common interface for audio playback objects (AudioPlayer, AudioPlaylist).
+ * Provides default implementations for operations shared between player types.
+ */
+interface Playable {
+ val id: String
+ var isPaused: Boolean
+ var isMuted: Boolean
+ var previousVolume: Float
+ var onPlaybackStateChange: ((Boolean) -> Unit)?
+
+ val player: Player
+ val appContext: AppContext?
+
+ val currentTime: Double get() = player.currentPosition / 1000.0
+ val duration: Double get() = if (player.duration != C.TIME_UNSET) player.duration / 1000.0 else 0.0
+ val isPlaying: Boolean get() = player.isPlaying
+ val volume: Float get() = player.volume
+
+ fun play() {
+ player.play()
+ }
+
+ fun pause() {
+ player.pause()
+ }
+
+ fun seekTo(seconds: Double) {
+ player.seekTo((seconds * 1000L).toLong())
+ }
+
+ fun setVolume(volume: Float?) {
+ appContext?.mainQueue?.launch {
+ val boundedVolume = volume?.coerceIn(0f, 1f) ?: 1f
+ if (isMuted) {
+ if (boundedVolume > 0f) {
+ previousVolume = boundedVolume
+ }
+ player.volume = 0f
+ } else {
+ previousVolume = boundedVolume
+ player.volume = boundedVolume
+ }
+ }
+ }
+
+ fun setPlaybackRate(rate: Float)
+ fun currentStatus(): Map
+}
diff --git a/packages/expo-audio/build/Audio.types.d.ts b/packages/expo-audio/build/Audio.types.d.ts
index 1f37f1d0ddba21..2f88e3f2ba1ce3 100644
--- a/packages/expo-audio/build/Audio.types.d.ts
+++ b/packages/expo-audio/build/Audio.types.d.ts
@@ -1,4 +1,18 @@
import { AudioQuality, IOSOutputFormat } from './RecordingConstants';
+/**
+ * Represents audio source information returned from native.
+ * This is the object returned when reading sources from a queue.
+ */
+export type AudioSourceInfo = {
+ /**
+ * A string representing the resource identifier for the audio.
+ */
+ uri?: string;
+ /**
+ * An optional display name for the audio source.
+ */
+ name?: string;
+};
export type AudioSource = string | number | null | {
/**
* A string representing the resource identifier for the audio,
@@ -15,6 +29,11 @@ export type AudioSource = string | number | null | {
* On web requires the `Access-Control-Allow-Origin` header returned by the server to include the current domain.
*/
headers?: Record;
+ /**
+ * An optional display name for the audio source.
+ * Useful for showing track names in a queue or playlist UI.
+ */
+ name?: string;
};
/**
* Options for configuring audio player behavior.
@@ -136,7 +155,7 @@ export type PitchCorrectionQuality = 'low' | 'medium' | 'high';
*/
export type AudioStatus = {
/** Unique identifier for the player instance. */
- id: number;
+ id: string;
/** Current playback position in seconds. */
currentTime: number;
/** String representation of the player's internal playback state. */
@@ -183,7 +202,7 @@ export type AudioStatus = {
*/
export type RecordingStatus = {
/** Unique identifier for the recording session. */
- id: number;
+ id: string;
/** Whether the recording has finished (stopped). */
isFinished: boolean;
/** Whether an error occurred during recording. */
@@ -532,4 +551,73 @@ export type AudioMetadata = {
albumTitle?: string;
artworkUrl?: string;
};
+/**
+ * Loop mode for audio playlist playback.
+ *
+ * - `'none'`: No looping. Playback stops after the last track.
+ * - `'single'`: Loops the current track indefinitely.
+ * - `'all'`: Loops the entire playlist, returning to the first track after the last.
+ */
+export type AudioPlaylistLoopMode = 'none' | 'single' | 'all';
+/**
+ * Options for configuring an audio playlist.
+ */
+export type AudioPlaylistOptions = {
+ /**
+ * Initial sources to add to the playlist. Each source can be a local asset, remote URL, or null.
+ * @default []
+ */
+ sources?: AudioSource[];
+ /**
+ * How often (in milliseconds) to emit playback status updates. Defaults to 500ms.
+ * @default 500
+ */
+ updateInterval?: number;
+ /**
+ * Loop mode for the playlist.
+ * - `'none'`: No looping (default)
+ * - `'single'`: Loop the current track
+ * - `'all'`: Loop the entire playlist
+ * @default 'none'
+ */
+ loop?: AudioPlaylistLoopMode;
+ /**
+ * Sets the `crossOrigin` attribute on the `