diff --git a/examples/audio-demo/src/test/kotlin/com/thelightphone/audiodemo/ToneScreenTest.kt b/examples/audio-demo/src/test/kotlin/com/thelightphone/audiodemo/ToneScreenTest.kt index 14c57bb0..230e1cfd 100644 --- a/examples/audio-demo/src/test/kotlin/com/thelightphone/audiodemo/ToneScreenTest.kt +++ b/examples/audio-demo/src/test/kotlin/com/thelightphone/audiodemo/ToneScreenTest.kt @@ -8,6 +8,8 @@ import com.thelightphone.sdk.audio.LightAudioPlayer import com.thelightphone.sdk.audio.LightAudioRecorder import com.thelightphone.sdk.audio.LightAudioUsage import com.thelightphone.sdk.audio.LightAudioVoice +import com.thelightphone.sdk.audio.LightMediaEnv +import com.thelightphone.sdk.audio.LightPlayerConfigurator import com.thelightphone.sdk.audio.RecorderConfig import kotlin.test.Test import kotlin.test.assertEquals @@ -26,7 +28,14 @@ class ToneScreenTest { val vm = ToneViewModel(object : LightAudio { override val capabilities: AudioCapabilities = AudioCapabilities(67) - override fun newPlayer(usage: LightAudioUsage): LightAudioPlayer { + override fun newPlayer( + usage: LightAudioUsage, + configure: LightPlayerConfigurator? + ): LightAudioPlayer { + TODO("should not be called") + } + + override fun mediaEnv(): LightMediaEnv { TODO("Should not be called") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4de00cef..66a41c04 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,6 +56,8 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +androidx-media3-datasource = { module = "androidx.media3:media3-datasource", version.ref = "media3" } +androidx-media3-database = { module = "androidx.media3:media3-database", version.ref = "media3" } androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } [plugins] diff --git a/sdk/client/README.md b/sdk/client/README.md index 5d5e093e..c75fb063 100644 --- a/sdk/client/README.md +++ b/sdk/client/README.md @@ -125,6 +125,22 @@ player.play() - Observe `isPlaying` for the actual state. - Transient focus loss pauses and later resumes playback, while duckable loss lowers the volume. +#### Advanced audio configuration +The default `newPlayer()` path builds an internal ExoPlayer and is unchanged for simple playback. Tools that have more complex audio needs can pass `LightPlayerConfigurator` to have more fine-grained control. +The SDK owns `Context` and constructs `ExoPlayer.Builder`. Your tool configures source factory, cache, load control, and related builder options via `LightPlayerConfigurator`. `LightMediaEnv` vends sandboxed cache, database, and data-source helpers rooted in the tool's private storage. +```kotlin +val player = audio.newPlayer(configure = LightPlayerConfigurator { builder, env -> + val cache = env.cache("stream", maxBytes = 64L * 1024 * 1024) + // configure builder with the things you need +}) +``` +Call `LightAudioPlayer.release` when done. +- Caches from `env.cache(...)` are process-scoped. +- Use `LightAudio.mediaEnv` for bankers, downloads, or other cache readers/writers. +- Do not call `SimpleCache.release()` from tool code. Player `release()` only tears down ExoPlayer. +- Use `LightAudioPlayer.media3Player` for direct queue editing (`replaceMediaItem`), per-item keys, and `Player.Listener` access. + + #### PCM voice `LightAudioVoice` plays short mono signed 16-bit PCM buffers. diff --git a/sdk/client/build.gradle.kts b/sdk/client/build.gradle.kts index df4774cf..25f945ea 100644 --- a/sdk/client/build.gradle.kts +++ b/sdk/client/build.gradle.kts @@ -62,8 +62,10 @@ dependencies { api(libs.androidx.room.runtime) api(libs.androidx.room.ktx) implementation(libs.androidx.work.runtime) - implementation(libs.androidx.media3.common) - implementation(libs.androidx.media3.exoplayer) + api(libs.androidx.media3.common) + api(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.datasource) + implementation(libs.androidx.media3.database) lintChecks(project(":lint-rules")) testImplementation(libs.kotlin.test) diff --git a/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightAudio.kt b/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightAudio.kt index ace86f85..ca0b14f2 100644 --- a/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightAudio.kt +++ b/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightAudio.kt @@ -2,11 +2,28 @@ package com.thelightphone.sdk.audio import android.content.Context import android.media.AudioManager +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi import com.thelightphone.sdk.SealedLightActivity interface LightAudio { val capabilities: AudioCapabilities - fun newPlayer(usage: LightAudioUsage = LightAudioUsage.Music): LightAudioPlayer + + /** + * Creates a player that requests audio focus appropriate for [usage]. + * When [configure] is non-null, the SDK builds an [ExoPlayer.Builder] and + * invokes it before constructing the player. + */ + @OptIn(UnstableApi::class) + fun newPlayer( + usage: LightAudioUsage = LightAudioUsage.Music, + configure: LightPlayerConfigurator? = null, + ): LightAudioPlayer + + /** Sandboxed media3 cache and data-source helpers for this tool process. */ + @OptIn(UnstableApi::class) + fun mediaEnv(): LightMediaEnv + fun newRecorder(cfg: RecorderConfig = RecorderConfig()): LightAudioRecorder fun newCapture(cfg: CaptureConfig = CaptureConfig()): LightAudioCapture fun newVoice( @@ -24,11 +41,18 @@ value class DefaultLightAudio( override val capabilities: AudioCapabilities get() = sealedActivity.activity.readAudioCapabilities() - /** Create a player that requests audio focus appropriate for [usage]. */ - override fun newPlayer(usage: LightAudioUsage): LightAudioPlayer { - return LightAudioPlayer(sealedActivity.activity, usage) + @OptIn(UnstableApi::class) + override fun newPlayer( + usage: LightAudioUsage, + configure: LightPlayerConfigurator?, + ): LightAudioPlayer { + return LightAudioPlayer.create(sealedActivity.activity, usage, configure) } + @OptIn(UnstableApi::class) + override fun mediaEnv(): LightMediaEnv = + LightMediaEnv.forContext(sealedActivity.activity) + /** Create a recorder using [cfg]. Call [LightAudioRecorder.release] when done. */ override fun newRecorder(cfg: RecorderConfig): LightAudioRecorder = LightAudioRecorder(sealedActivity.activity, cfg) diff --git a/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightAudioPlayer.kt b/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightAudioPlayer.kt index 30334a21..a73f74c7 100644 --- a/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightAudioPlayer.kt +++ b/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightAudioPlayer.kt @@ -3,6 +3,7 @@ package com.thelightphone.sdk.audio import android.content.Context import android.media.AudioManager import android.net.Uri +import androidx.annotation.OptIn import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata @@ -29,9 +30,10 @@ import kotlinx.coroutines.flow.StateFlow * Transient focus loss pauses and later resumes playback; duckable loss lowers * volume. Call [release] when the owning screen is destroyed. */ -class LightAudioPlayer internal constructor( +open class LightAudioPlayerCore internal constructor( context: Context, - usage: LightAudioUsage = LightAudioUsage.Music + private val usage: LightAudioUsage, + private val player: Player, ) { private val scopeJob = SupervisorJob() private val scope = CoroutineScope(scopeJob + Dispatchers.Main.immediate) @@ -45,52 +47,59 @@ class LightAudioPlayer internal constructor( /** Current position in milliseconds, updated while playing. */ val positionMs: StateFlow = _positionMs + /** Resolved duration in milliseconds, or `0` while unknown/unavailable. */ val durationMs: StateFlow = _durationMs + /** Whether the platform is actively advancing playback. */ val isPlaying: StateFlow = _isPlaying + /** Current queue index, or `-1` when the queue is empty. */ val currentMediaItemIndex: StateFlow = _currentMediaItemIndex - private val player = ExoPlayer.Builder(context).build().apply player@{ - setAudioAttributes(usage.toMedia3AudioAttributes(), false) - addListener(object : Player.Listener { - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - // `this@player` is the ExoPlayer (Int index), not the wrapper's StateFlow. - _currentMediaItemIndex.value = if (mediaItem == null) { - NO_MEDIA_ITEM - } else { - this@player.currentMediaItemIndex - } - } + private val focus = AudioFocusHelper( + context = context, + usage = usage, + gainType = AudioManager.AUDIOFOCUS_GAIN, + onFocusChange = ::onAudioFocusChange + ) - override fun onIsPlayingChanged(isPlaying: Boolean) { - _isPlaying.value = isPlaying - if (isPlaying) { - startPositionUpdates() - } else { - stopPositionUpdates() - updatePosition() - } + private val stateListener = object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + _currentMediaItemIndex.value = if (mediaItem == null) { + NO_MEDIA_ITEM + } else { + player.currentMediaItemIndex } + } - override fun onPlaybackStateChanged(playbackState: Int) { - updateDuration() + override fun onIsPlayingChanged(isPlaying: Boolean) { + _isPlaying.value = isPlaying + if (isPlaying) { + startPositionUpdates() + } else { + stopPositionUpdates() updatePosition() - if (playbackState == Player.STATE_ENDED) { - stopPositionUpdates() - abandonFocus() - } } - }) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + updateDuration() + updatePosition() + if (playbackState == Player.STATE_ENDED) { + stopPositionUpdates() + abandonFocus() + } + } } - private val focus = AudioFocusHelper( - context = context, - usage = usage, - gainType = AudioManager.AUDIOFOCUS_GAIN, - onFocusChange = ::onAudioFocusChange - ) + init { + reassertSdkOutput(player, usage) + player.addListener(stateListener) + } + + /** The underlying media3 [Player] for direct queue and listener access. */ + fun media3Player(): Player = player /** Playback rate, clamped to a minimum positive rate. */ var speed: Float = 1.0f @@ -99,22 +108,6 @@ class LightAudioPlayer internal constructor( player.playbackParameters = PlaybackParameters(field) } - /** Enables the platform player's silence-skipping behavior. */ - var skipSilence: Boolean = false - @androidx.annotation.OptIn(markerClass = [UnstableApi::class]) - set(value) { - field = value - player.skipSilenceEnabled = value - } - - /** When `true`, playback pauses at the end of each queue item instead of advancing. */ - var pauseAtEndOfMediaItems: Boolean = false - @androidx.annotation.OptIn(markerClass = [UnstableApi::class]) - set(value) { - field = value - player.pauseAtEndOfMediaItems = value - } - /** Replaces the queue with [file] and prepares it for playback. */ fun setSource(file: File) { setQueue(listOf(file), metadata = null) @@ -208,12 +201,12 @@ class LightAudioPlayer internal constructor( } /** Permanently releases playback, focus, and state-update resources. Idempotent. */ - fun release() { + open fun release() { if (released) return released = true stopPositionUpdates() abandonFocus() - player.release() + player.removeListener(stateListener) scope.cancel() } @@ -270,6 +263,63 @@ class LightAudioPlayer internal constructor( private fun updateDuration() { _durationMs.value = player.duration.validDuration() } + + internal companion object { + @OptIn(UnstableApi::class) + internal fun create( + context: Context, + usage: LightAudioUsage, + player: Player + ): LightAudioPlayerCore { + return LightAudioPlayerCore(context, usage, player) + } + + private fun reassertSdkOutput(player: Player, usage: LightAudioUsage) { + player.setAudioAttributes(usage.toMedia3AudioAttributes(), false) + } + } +} + +class LightAudioPlayer internal constructor( + context: Context, + usage: LightAudioUsage, + private val exoPlayer: ExoPlayer +) : + LightAudioPlayerCore(context, usage, exoPlayer) { + /** Enables the platform player's silence-skipping behavior. */ + var skipSilence: Boolean = false + @OptIn(UnstableApi::class) + set(value) { + field = value + exoPlayer.skipSilenceEnabled = value + } + + /** When `true`, playback pauses at the end of each queue item instead of advancing. */ + var pauseAtEndOfMediaItems: Boolean = false + @OptIn(UnstableApi::class) + set(value) { + field = value + exoPlayer.pauseAtEndOfMediaItems = value + } + + override fun release() { + super.release() + exoPlayer.release() + } + + internal companion object { + @OptIn(UnstableApi::class) + internal fun create( + context: Context, + usage: LightAudioUsage, + configure: LightPlayerConfigurator?, + ): LightAudioPlayer { + val builder = ExoPlayer.Builder(context) + configure?.configure(builder, LightMediaEnv.forContext(context)) + val exoPlayer = builder.build() + return LightAudioPlayer(context, usage, exoPlayer) + } + } } internal fun LightAudioItem.toMediaItem(queueIndex: Int): MediaItem { diff --git a/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightMediaEnv.kt b/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightMediaEnv.kt new file mode 100644 index 00000000..76b2cdd9 --- /dev/null +++ b/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightMediaEnv.kt @@ -0,0 +1,77 @@ +package com.thelightphone.sdk.audio + +import android.content.Context +import androidx.media3.common.util.UnstableApi +import androidx.media3.database.DatabaseProvider +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.NoOpCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Sandboxed media3 primitives rooted in the tool's private storage. + * + * This type vends the helpers needed to configure streaming playback. Named caches + * are shared for the tool process. [LightAudioPlayer.release] does not release + * them. + * + * @property filesDir the tool's private files directory + * @property cacheDir the tool's private cache directory + */ +@UnstableApi +class LightMediaEnv internal constructor(context: Context) { + private val appContext = context.applicationContext + val filesDir: File = context.filesDir + val cacheDir: File = context.cacheDir + + private val databaseProviders = mutableMapOf() + + /** Returns a shared [DatabaseProvider] for media cache indices. */ + fun databaseProvider(): DatabaseProvider = + databaseProviders.getOrPut(DEFAULT_DB) { + StandaloneDatabaseProvider(appContext) + } + + /** + * Opens or returns a named [Cache] under tool storage. + * + * When [maxBytes] is `null`, the cache is pinned under [filesDir] with a + * [NoOpCacheEvictor]. Otherwise an LRU cache under [cacheDir] is capped at + * [maxBytes]. One [SimpleCache] instance is shared per cache directory for + * the tool process. We are protecting against multiple instances in one cache directory. + */ + fun cache(name: String, maxBytes: Long? = null): Cache { + val (directory, evictor) = if (maxBytes == null) { + File(filesDir, "$CACHE_DIR_PREFIX/$name").also { it.mkdirs() } to NoOpCacheEvictor() + } else { + File(cacheDir, "$CACHE_DIR_PREFIX/$name").also { it.mkdirs() } to + LeastRecentlyUsedCacheEvictor(maxBytes) + } + val pathKey = directory.canonicalPath + return cachesByPath.getOrPut(pathKey) { + SimpleCache(directory, evictor, databaseProvider()) + } + } + + /** Wraps [upstream] in a platform [DefaultDataSource.Factory]. */ + fun dataSourceFactory(upstream: DataSource.Factory): DataSource.Factory = + DefaultDataSource.Factory(appContext, upstream) + + internal companion object { + private val envs = ConcurrentHashMap() + private val cachesByPath = ConcurrentHashMap() + + internal fun forContext(context: Context): LightMediaEnv { + val appContext = context.applicationContext + return envs.getOrPut(appContext) { LightMediaEnv(appContext) } + } + } +} + +private const val DEFAULT_DB = "default" +private const val CACHE_DIR_PREFIX = "light-media-cache" diff --git a/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightPlayerConfigurator.kt b/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightPlayerConfigurator.kt new file mode 100644 index 00000000..3ba559aa --- /dev/null +++ b/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightPlayerConfigurator.kt @@ -0,0 +1,12 @@ +package com.thelightphone.sdk.audio + +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer + +/** + * Configures an [ExoPlayer.Builder] before the SDK builds the player. + */ +@UnstableApi +fun interface LightPlayerConfigurator { + fun configure(builder: ExoPlayer.Builder, env: LightMediaEnv) +}