diff --git a/examples/audio-demo/src/main/kotlin/com/thelightphone/audiodemo/HomeScreen.kt b/examples/audio-demo/src/main/kotlin/com/thelightphone/audiodemo/HomeScreen.kt index 326469b0..9bea4517 100644 --- a/examples/audio-demo/src/main/kotlin/com/thelightphone/audiodemo/HomeScreen.kt +++ b/examples/audio-demo/src/main/kotlin/com/thelightphone/audiodemo/HomeScreen.kt @@ -52,6 +52,7 @@ class HomeScreen(sealedActivity: SealedLightActivity) : .padding(horizontal = 1f.gridUnitsAsDp()), ) { MenuRow("PLAYER") { navigateTo(::PlayerScreen) } + MenuRow("BYO STREAM") { navigateTo(::StreamingScreen) } MenuRow("RECORDER") { navigateTo(::RecorderScreen) } MenuRow("CAPTURE") { navigateTo(::CaptureScreen) } MenuRow("TONE") { navigateTo(::ToneScreen) } diff --git a/examples/audio-demo/src/main/kotlin/com/thelightphone/audiodemo/StreamingScreen.kt b/examples/audio-demo/src/main/kotlin/com/thelightphone/audiodemo/StreamingScreen.kt new file mode 100644 index 00000000..5df1c125 --- /dev/null +++ b/examples/audio-demo/src/main/kotlin/com/thelightphone/audiodemo/StreamingScreen.kt @@ -0,0 +1,299 @@ +package com.thelightphone.audiodemo + +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PriorityTaskManager +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.ByteArrayDataSource +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MediaSource +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.audio.DefaultLightAudio +import com.thelightphone.sdk.audio.LightAudio +import com.thelightphone.sdk.audio.LightAudioItem +import com.thelightphone.sdk.audio.LightAudioPlayer +import com.thelightphone.sdk.audio.LightAudioSource +import com.thelightphone.sdk.audio.LightAudioUsage +import com.thelightphone.sdk.audio.LightExoPlayerConfigurer +import com.thelightphone.sdk.audio.LightMediaMetadata +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightBottomBar +import com.thelightphone.sdk.ui.LightIcons +import com.thelightphone.sdk.ui.LightScrollView +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter +import com.thelightphone.sdk.ui.gridUnitsAsDp +import com.thelightphone.sdk.ui.lightClickable +import java.io.File + +/** + * Proof case for BYO ExoPlayer: custom scheme + cache + load control + priority, + * all adopted through [LightAudio.newPlayer] without importing Android Context. + */ +@OptIn(UnstableApi::class) +class StreamingViewModel( + audio: LightAudio, + readAsset: (String) -> ByteArray, +) : LightViewModel() { + private val demoBytes: Map = mapOf( + "track1" to readAsset("audio/small-talk-build-iv.ogg"), + ) + + private val player: LightAudioPlayer = audio.newPlayer(LightAudioUsage.Music) { + buildStreamingPlayer(demoBytes) + } + + val positionMs = player.positionMs + val durationMs = player.durationMs + val isPlaying = player.isPlaying + + init { + player.setMediaQueue( + listOf( + LightAudioItem( + source = LightAudioSource.CustomSource("demo:///track1"), + metadata = LightMediaMetadata( + title = "Demo custom source", + artist = "BYO ExoPlayer", + album = "audio-demo", + ), + mimeType = "audio/ogg", + customCacheKey = "demo:track1", + mediaId = "demo:track1", + ), + LightAudioItem( + source = LightAudioSource.UrlSource( + "https://ice6.somafm.com/groovesalad-128-mp3", + ), + metadata = LightMediaMetadata( + title = "SomaFM (via default factory)", + artist = "STREAM", + ), + mediaId = "stream:groovesalad", + ), + ), + ) + } + + fun play() = player.play() + fun pause() = player.pause() + fun togglePlayPause() { + if (isPlaying.value) pause() else play() + } + + fun skipToNext() = player.skipToNext() + fun skipToPrevious() = player.skipToPrevious() + + override fun onCleared() { + player.release() + super.onCleared() + } +} + +@OptIn(UnstableApi::class) +private fun LightExoPlayerConfigurer.buildStreamingPlayer( + demoBytes: Map, +): ExoPlayer { + val priorityTaskManager = PriorityTaskManager() + val cache = SimpleCache( + File(cacheDir, "audio-demo-stream"), + LeastRecentlyUsedCacheEvictor(16L * 1024L * 1024L), + databaseProvider(), + ) + val upstream = CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(defaultDataSourceFactory()) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + .setUpstreamPriorityTaskManager(priorityTaskManager) + .setUpstreamPriority(C.PRIORITY_PLAYBACK) + val defaults = DefaultMediaSourceFactory(upstream) + val demoFactory = DemoSchemeMediaSourceFactory(demoBytes, defaults) + val loadControl = DefaultLoadControl.Builder() + .setBufferDurationsMs( + /* minBufferMs= */ 10_000, + /* maxBufferMs= */ 40_000, + /* bufferForPlaybackMs= */ 1_500, + /* bufferForPlaybackAfterRebufferMs= */ 3_000, + ) + .build() + return builder + .setMediaSourceFactory(demoFactory) + .setLoadControl(loadControl) + .setPriorityTaskManager(priorityTaskManager) + .setPriority(C.PRIORITY_PLAYBACK) + .build() +} + +/** + * Resolves `demo:///` from in-memory bytes; delegates every other URI to + * [defaults] so file/asset/http(s) keep working on the same player. + */ +@UnstableApi +private class DemoSchemeMediaSourceFactory( + private val demoBytes: Map, + private val defaults: DefaultMediaSourceFactory, +) : MediaSource.Factory { + override fun setDrmSessionManagerProvider( + drmSessionManagerProvider: androidx.media3.exoplayer.drm.DrmSessionManagerProvider, + ): MediaSource.Factory { + defaults.setDrmSessionManagerProvider(drmSessionManagerProvider) + return this + } + + override fun setLoadErrorHandlingPolicy( + loadErrorHandlingPolicy: androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy, + ): MediaSource.Factory { + defaults.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) + return this + } + + override fun getSupportedTypes(): IntArray = defaults.supportedTypes + + override fun createMediaSource(mediaItem: MediaItem): MediaSource { + val uri = mediaItem.localConfiguration?.uri + if (uri != null && uri.scheme == DEMO_SCHEME) { + val id = uri.pathSegments.firstOrNull().orEmpty() + val bytes = demoBytes[id] + ?: error("Unknown demo track id: $id") + val progressive = DefaultMediaSourceFactory( + DataSource.Factory { + object : DataSource { + private val inner = ByteArrayDataSource(bytes) + override fun addTransferListener(transferListener: TransferListener) { + inner.addTransferListener(transferListener) + } + + override fun open(dataSpec: DataSpec): Long = inner.open(dataSpec) + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + inner.read(buffer, offset, length) + + override fun getUri() = inner.uri + override fun close() = inner.close() + } + }, + ) + return progressive.createMediaSource(mediaItem) + } + return defaults.createMediaSource(mediaItem) + } + + companion object { + const val DEMO_SCHEME = "demo" + } +} + +@OptIn(UnstableApi::class) +class StreamingScreen(private val sealedActivity: SealedLightActivity) : + LightScreen(sealedActivity) { + override val viewModelClass = StreamingViewModel::class.java + + override fun createViewModel() = StreamingViewModel( + DefaultLightAudio(sealedActivity), + lightContext::readAsset, + ) + + @Composable + override fun Content() { + val colors by LightThemeController.colors.collectAsState() + val position by viewModel.positionMs.collectAsState() + val duration by viewModel.durationMs.collectAsState() + val playing by viewModel.isPlaying.collectAsState() + + LightTheme(colors = colors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background), + ) { + LightTopBar( + leftButton = LightBarButton.LightIcon(LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("BYO Stream"), + ) + LightScrollView( + modifier = Modifier + .weight(1f) + .padding(horizontal = 1f.gridUnitsAsDp()), + ) { + LightText( + text = "Configured ExoPlayer with cache, load control, " + + "priority, and demo:/// custom source.", + variant = LightTextVariant.Copy, + modifier = Modifier.padding(vertical = 0.75f.gridUnitsAsDp()), + ) + LightText( + text = "Track 1: demo:///track1 (ogg bytes)", + variant = LightTextVariant.Fine, + modifier = Modifier + .fillMaxWidth() + .lightClickable { viewModel.play() } + .padding(vertical = 0.5f.gridUnitsAsDp()), + ) + LightText( + text = "Track 2: SomaFM URL (delegated factory)", + variant = LightTextVariant.Fine, + modifier = Modifier + .fillMaxWidth() + .lightClickable { + viewModel.skipToNext() + viewModel.play() + } + .padding(vertical = 0.5f.gridUnitsAsDp()), + ) + } + LightText( + text = "${formatDuration(position)} / ${formatDuration(duration)}", + variant = LightTextVariant.Fine, + align = TextAlign.Center, + monospace = true, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 0.5f.gridUnitsAsDp()), + ) + LightBottomBar( + items = listOf( + LightBarButton.LightIcon( + LightIcons.REWIND, + viewModel::skipToPrevious, + contentDescription = "Previous track", + ), + LightBarButton.LightIcon( + if (playing) LightIcons.PAUSE else LightIcons.PLAY, + viewModel::togglePlayPause, + ), + LightBarButton.LightIcon( + LightIcons.FAST_FORWARD, + viewModel::skipToNext, + contentDescription = "Next track", + ), + ), + ) + } + } + } +} 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..783182cf 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 @@ -30,6 +30,15 @@ class ToneScreenTest { TODO("Should not be called") } + @androidx.annotation.OptIn(markerClass = [androidx.media3.common.util.UnstableApi::class]) + override fun newPlayer( + usage: LightAudioUsage, + configure: com.thelightphone.sdk.audio.LightExoPlayerConfigurer.() -> + androidx.media3.exoplayer.ExoPlayer, + ): LightAudioPlayer { + TODO("Should not be called") + } + override fun newRecorder(cfg: RecorderConfig): LightAudioRecorder { TODO("Should not be called") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5178687d..d1918d37 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,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..bacc007c 100644 --- a/sdk/client/README.md +++ b/sdk/client/README.md @@ -125,6 +125,54 @@ player.play() - Observe `isPlaying` for the actual state. - Transient focus loss pauses and later resumes playback, while duckable loss lowers the volume. +#### Advanced: bring your own player + +For streaming clients that need Media3 builder-time control (custom `MediaSource.Factory`, +`SimpleCache`, `PriorityTaskManager`, `LoadControl`, offline `DownloadManager` wiring), use +the opt-in overload. The default `newPlayer(usage)` path is unchanged. + +| Layer | Owner | +|---|---| +| Source factory, cache, priority, load control, DownloadManager, extra listeners | Tool (configure before handoff) | +| Audio attributes, audio focus, play/pause/seek API, `release()` | SDK (re-asserted after adoption) | + +```kotlin +@OptIn(UnstableApi::class) +val player = audio.newPlayer(LightAudioUsage.Music) { + val cache = SimpleCache( + File(cacheDir, "stream-cache"), + LeastRecentlyUsedCacheEvictor(64L * 1024L * 1024L), + databaseProvider(), + ) + val upstream = CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(defaultDataSourceFactory()) + builder + .setMediaSourceFactory(DefaultMediaSourceFactory(upstream)) + .setLoadControl( + DefaultLoadControl.Builder() + .setBufferDurationsMs(15_000, 50_000, 2_500, 5_000) + .build(), + ) + .setPriorityTaskManager(PriorityTaskManager()) + .build() +} +``` + +Ownership rules: +- Do not request audio focus yourself; the SDK owns focus via `AudioFocusHelper`. +- Do not retain or `release()` the `ExoPlayer` after handoff — call `LightAudioPlayer.release()`. +- If you replace the media-source factory, still delegate file/asset/http(s) (compose with + `defaultMediaSourceFactory()` / `DefaultMediaSourceFactory`) when those sources are used. +- Resolve network-bound URLs **off the main thread** before `setMediaQueue`; do not block + inside `MediaSource.Factory.createMediaSource`. +- Use [LightAudioSource.CustomSource] for non-standard URI schemes, and set optional + `mimeType` / `customCacheKey` / `mediaId` on [LightAudioItem] when the playable URI is + ephemeral or extension-less (e.g. DASH MPD, CDN URLs). + +`LightExoPlayerConfigurer` exposes `builder`, `filesDir`, `cacheDir`, and Media3 factory +helpers so tools never import Android `Context` (blocked by the Light SDK plugin). + #### 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..c180609d 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) + api(libs.androidx.media3.datasource) + api(libs.androidx.media3.database) lintChecks(project(":lint-rules")) testImplementation(libs.kotlin.test) diff --git a/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/AudioTypes.kt b/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/AudioTypes.kt index f3e058a2..6fcdd2d6 100644 --- a/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/AudioTypes.kt +++ b/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/AudioTypes.kt @@ -160,6 +160,14 @@ sealed interface LightAudioSource { data class AssetSource(val assetPath: String) : LightAudioSource /** A local or remote [url] supported by the platform player. */ data class UrlSource(val url: String) : LightAudioSource + /** + * A caller-defined [uri] resolved by a custom `MediaSource.Factory` on a + * tool-supplied player (see [LightAudio.newPlayer] with a player configurer). + * Use for streams that cannot be a plain URL — e.g. session-bound or + * decrypted sources. Prefer [LightAudioItem.mimeType] when the [uri] has no + * resolvable extension so the platform extractor can pick a container. + */ + data class CustomSource(val uri: String) : LightAudioSource } /** @@ -167,10 +175,16 @@ sealed interface LightAudioSource { * * @property source playable media location * @property metadata descriptive values forwarded to playback surfaces + * @property mimeType optional container hint for extension-less URIs (e.g. DASH MPD) + * @property customCacheKey optional stable cache key when the playable URI is ephemeral + * @property mediaId optional stable queue identity; defaults to the source URI string */ data class LightAudioItem( val source: LightAudioSource, val metadata: LightMediaMetadata, + val mimeType: String? = null, + val customCacheKey: String? = null, + val mediaId: String? = null, ) internal const val DEFAULT_SAMPLE_RATE = 48_000 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..f51a2050 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,33 @@ package com.thelightphone.sdk.audio import android.content.Context import android.media.AudioManager +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer import com.thelightphone.sdk.SealedLightActivity interface LightAudio { val capabilities: AudioCapabilities fun newPlayer(usage: LightAudioUsage = LightAudioUsage.Music): LightAudioPlayer + /** + * Create a player that adopts a caller-configured [ExoPlayer]. + * + * The [configure] receiver provides an [ExoPlayer.Builder] (application + * context) plus [LightExoPlayerConfigurer] helpers for media-source and + * cache factories without importing Android `Context`. Return a fully built + * `ExoPlayer` (source factory, cache, priority, load control, listeners). + * The SDK re-asserts audio attributes, audio focus, and lifecycle after + * adoption; do not manage focus, retain, or release the instance yourself. + * For streams that cannot be a plain URL, resolve + * [LightAudioSource.CustomSource] URIs via the factory on this player. A + * custom media-source factory must still delegate file/asset/http(s) + * schemes if those sources are used on the same player. Do not perform + * network I/O on the main thread inside a media-source factory. + */ + @androidx.annotation.OptIn(markerClass = [UnstableApi::class]) + fun newPlayer( + usage: LightAudioUsage = LightAudioUsage.Music, + configure: LightExoPlayerConfigurer.() -> ExoPlayer, + ): LightAudioPlayer fun newRecorder(cfg: RecorderConfig = RecorderConfig()): LightAudioRecorder fun newCapture(cfg: CaptureConfig = CaptureConfig()): LightAudioCapture fun newVoice( @@ -29,6 +51,14 @@ value class DefaultLightAudio( return LightAudioPlayer(sealedActivity.activity, usage) } + @androidx.annotation.OptIn(markerClass = [UnstableApi::class]) + override fun newPlayer( + usage: LightAudioUsage, + configure: LightExoPlayerConfigurer.() -> ExoPlayer, + ): LightAudioPlayer { + return LightAudioPlayer(sealedActivity.activity, usage, configure) + } + /** 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..d1dab295 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 @@ -28,11 +28,20 @@ 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. + * + * When constructed with a player [configure] callback, the SDK adopts the + * returned [ExoPlayer] and re-asserts audio attributes, audio focus, and + * lifecycle ownership. The caller may configure source resolution, caching, + * priority, load control, and additional listeners on the instance, but must + * not manage audio focus, must not retain the instance after handoff, and must + * not call [ExoPlayer.release] on it directly — [release] owns teardown. */ class LightAudioPlayer internal constructor( context: Context, - usage: LightAudioUsage = LightAudioUsage.Music + usage: LightAudioUsage = LightAudioUsage.Music, + configure: (LightExoPlayerConfigurer.() -> ExoPlayer)? = null, ) { + private val appContext = context.applicationContext private val scopeJob = SupervisorJob() private val scope = CoroutineScope(scopeJob + Dispatchers.Main.immediate) private val _positionMs = MutableStateFlow(0L) @@ -52,7 +61,13 @@ class LightAudioPlayer internal constructor( /** Current queue index, or `-1` when the queue is empty. */ val currentMediaItemIndex: StateFlow = _currentMediaItemIndex - private val player = ExoPlayer.Builder(context).build().apply player@{ + @androidx.annotation.OptIn(markerClass = [UnstableApi::class]) + private val player = ( + configure?.let { block -> + LightExoPlayerConfigurer(ExoPlayer.Builder(appContext), appContext).block() + } ?: ExoPlayer.Builder(appContext).build() + ).apply player@{ + // SDK re-asserts output + focus ownership after adopting the player. setAudioAttributes(usage.toMedia3AudioAttributes(), false) addListener(object : Player.Listener { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { @@ -86,7 +101,7 @@ class LightAudioPlayer internal constructor( } private val focus = AudioFocusHelper( - context = context, + context = appContext, usage = usage, gainType = AudioManager.AUDIOFOCUS_GAIN, onFocusChange = ::onAudioFocusChange @@ -273,18 +288,39 @@ class LightAudioPlayer internal constructor( } internal fun LightAudioItem.toMediaItem(queueIndex: Int): MediaItem { - val uri = Uri.parse(source.uriString()) - return MediaItem.Builder() - .setUri(uri) - .setMediaId(uri.toString()) + val mapping = mediaItemMapping() + val builder = MediaItem.Builder() + .setUri(Uri.parse(mapping.uri)) + .setMediaId(mapping.mediaId) .setMediaMetadata(metadata.toMedia3Metadata(queueIndex)) - .build() + mapping.mimeType?.let(builder::setMimeType) + mapping.customCacheKey?.let(builder::setCustomCacheKey) + return builder.build() +} + +/** Pure queue → MediaItem field mapping (unit-testable without constructing MediaItem). */ +internal data class MediaItemMapping( + val uri: String, + val mediaId: String, + val mimeType: String?, + val customCacheKey: String?, +) + +internal fun LightAudioItem.mediaItemMapping(): MediaItemMapping { + val uri = source.uriString() + return MediaItemMapping( + uri = uri, + mediaId = mediaId ?: uri, + mimeType = mimeType, + customCacheKey = customCacheKey, + ) } internal fun LightAudioSource.uriString(): String = when (this) { is LightAudioSource.FileSource -> Uri.fromFile(file).toString() is LightAudioSource.AssetSource -> "asset:///${assetPath.trimStart('/')}" is LightAudioSource.UrlSource -> url + is LightAudioSource.CustomSource -> uri } private fun LightMediaMetadata.toMedia3Metadata(queueIndex: Int): MediaMetadata { diff --git a/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightExoPlayerConfigurer.kt b/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightExoPlayerConfigurer.kt new file mode 100644 index 00000000..9f482936 --- /dev/null +++ b/sdk/client/src/main/kotlin/com/thelightphone/sdk/audio/LightExoPlayerConfigurer.kt @@ -0,0 +1,42 @@ +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.DefaultDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import java.io.File + +/** + * Configures an [ExoPlayer] for [LightAudio.newPlayer] without exposing Android + * [Context] to tool source (which the Light SDK plugin blocks from importing). + * + * Set media-source, cache, priority, and load-control options on [builder], then + * return [ExoPlayer.Builder.build]. The SDK adopts that instance and re-asserts + * audio attributes, focus, and lifecycle. + */ +@UnstableApi +class LightExoPlayerConfigurer internal constructor( + val builder: ExoPlayer.Builder, + private val appContext: Context, +) { + /** Application files directory — durable caches and offline pins. */ + val filesDir: File get() = appContext.filesDir + + /** Application cache directory — evictable streaming LRUs. */ + val cacheDir: File get() = appContext.cacheDir + + /** Default media-source factory for file, asset, and http(s) URIs. */ + fun defaultMediaSourceFactory(): DefaultMediaSourceFactory = + DefaultMediaSourceFactory(appContext) + + /** Default upstream data-source factory (file / asset / http). */ + fun defaultDataSourceFactory(): DefaultDataSource.Factory = + DefaultDataSource.Factory(appContext) + + /** Media3 database used by [androidx.media3.datasource.cache.SimpleCache]. */ + fun databaseProvider(): DatabaseProvider = + StandaloneDatabaseProvider(appContext) +} diff --git a/sdk/client/src/test/kotlin/com/thelightphone/sdk/audio/LightAudioPlayerTest.kt b/sdk/client/src/test/kotlin/com/thelightphone/sdk/audio/LightAudioPlayerTest.kt index 722261c9..305452ac 100644 --- a/sdk/client/src/test/kotlin/com/thelightphone/sdk/audio/LightAudioPlayerTest.kt +++ b/sdk/client/src/test/kotlin/com/thelightphone/sdk/audio/LightAudioPlayerTest.kt @@ -2,6 +2,7 @@ package com.thelightphone.sdk.audio import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull class LightAudioPlayerTest { @Test @@ -13,7 +14,7 @@ class LightAudioPlayerTest { } @Test - fun sourceUriMapsAssetsAndUrls() { + fun sourceUriMapsAssetsUrlsAndCustom() { assertEquals( "asset:///audio/sample.ogg", LightAudioSource.AssetSource("/audio/sample.ogg").uriString(), @@ -22,5 +23,42 @@ class LightAudioPlayerTest { "https://example.com/live.mp3", LightAudioSource.UrlSource("https://example.com/live.mp3").uriString(), ) + assertEquals( + "demo:///track1", + LightAudioSource.CustomSource("demo:///track1").uriString(), + ) + } + + @Test + fun mediaItemMappingForwardsMimeCacheKeyAndMediaId() { + val item = LightAudioItem( + source = LightAudioSource.CustomSource("https://cdn.example/ephemeral"), + metadata = LightMediaMetadata(title = "Track"), + mimeType = "application/dash+xml", + customCacheKey = "stable:track:1", + mediaId = "catalog:track:1", + ) + assertEquals( + MediaItemMapping( + uri = "https://cdn.example/ephemeral", + mediaId = "catalog:track:1", + mimeType = "application/dash+xml", + customCacheKey = "stable:track:1", + ), + item.mediaItemMapping(), + ) + } + + @Test + fun mediaItemMappingDefaultsMediaIdToUriWhenOmitted() { + val item = LightAudioItem( + source = LightAudioSource.UrlSource("https://example.com/a.mp3"), + metadata = LightMediaMetadata(title = "A"), + ) + val mapping = item.mediaItemMapping() + assertEquals("https://example.com/a.mp3", mapping.uri) + assertEquals("https://example.com/a.mp3", mapping.mediaId) + assertNull(mapping.mimeType) + assertNull(mapping.customCacheKey) } }