diff --git a/.github/nightly-notes/2026-10-24-fix-music-offload-cache-resolve.md b/.github/nightly-notes/2026-10-24-fix-music-offload-cache-resolve.md new file mode 100644 index 0000000000..97f1656f1a --- /dev/null +++ b/.github/nightly-notes/2026-10-24-fix-music-offload-cache-resolve.md @@ -0,0 +1,6 @@ +## Fixed +- Songs no longer play silently or stay on loading on some phones. Audio offload is now off by default and can be turned on in the music player's Audio settings (#1417, #1422) +- A song that was partly played before no longer stops with an error when it plays again +- A local song that cannot be read while offline is skipped instead of loading for minutes +- Songs stream in one piece instead of reconnecting after an error every 30 seconds +- A song opened from a YouTube Music link is no longer replaced by your last queue when Flow starts diff --git a/app/src/main/java/io/github/aedev/flow/data/download/ChunkStitchingDataSource.kt b/app/src/main/java/io/github/aedev/flow/data/download/ChunkStitchingDataSource.kt new file mode 100644 index 0000000000..0eca4bd1f8 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/download/ChunkStitchingDataSource.kt @@ -0,0 +1,89 @@ +package io.github.aedev.flow.data.download + +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import java.io.IOException + +/** + * Reads a streamed song as one stream although [DownloadUtil] fetches it in bounded chunks. An + * open-ended read used to stop where its chunk stopped, so the extractor, cut off mid-block, threw + * an EOFException and the loader reopened the song after a load error every 512 KB. When such a + * read runs out, this opens the same chain again at the next byte, which is the reopen the loader's + * retry made, and keeps reading. A song on the device is read as it is. Media3 has no data source + * that carries a read on across ranged requests. + */ +@OptIn(UnstableApi::class) +internal class ChunkStitchingDataSource( + private val upstream: DataSource, +) : DataSource { + private var stitched: DataSpec? = null + private var bytesRead = 0L + private var bytesSinceOpen = 0L + + override fun addTransferListener(transferListener: TransferListener) = upstream.addTransferListener(transferListener) + + override fun open(dataSpec: DataSpec): Long { + val length = upstream.open(dataSpec) + val streamed = + dataSpec.length == C.LENGTH_UNSET.toLong() && + dataSpec.uri.scheme == LocalCopyDataSource.MUSIC_SCHEME && + upstream.uri?.scheme !in LocalCopyDataSource.DEVICE_SCHEMES + stitched = dataSpec.takeIf { streamed } + bytesRead = 0 + bytesSinceOpen = 0 + return if (streamed) C.LENGTH_UNSET.toLong() else length + } + + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { + if (length == 0) return 0 + val read = upstream.read(buffer, offset, length) + val dataSpec = stitched ?: return read + if (read != C.RESULT_END_OF_INPUT) { + bytesRead += read + bytesSinceOpen += read + return read + } + if (bytesSinceOpen == 0L || !openNextChunk(dataSpec)) return C.RESULT_END_OF_INPUT + return read(buffer, offset, length) + } + + /** + * False ends the read where it stands. Past the song's last byte the next open fails (416, or + * no network for a song the cache holds whole), and the extractor has already read to its end. + * Mid-song, a failure leaves the extractor short, which raises the same load error a chunk end + * raised before, so the loader's retry runs as it always did. + */ + private fun openNextChunk(dataSpec: DataSpec): Boolean { + upstream.close() + bytesSinceOpen = 0 + return try { + upstream.open(dataSpec.subrange(bytesRead)) != 0L + } catch (e: IOException) { + false + } + } + + override fun getUri(): Uri? = upstream.uri + + override fun getResponseHeaders(): Map> = upstream.responseHeaders + + override fun close() { + stitched = null + upstream.close() + } + + class Factory( + private val upstream: DataSource.Factory, + ) : DataSource.Factory { + override fun createDataSource(): DataSource = ChunkStitchingDataSource(upstream.createDataSource()) + } +} diff --git a/app/src/main/java/io/github/aedev/flow/data/download/DownloadUtil.kt b/app/src/main/java/io/github/aedev/flow/data/download/DownloadUtil.kt index 3498a928af..4c726a4a05 100644 --- a/app/src/main/java/io/github/aedev/flow/data/download/DownloadUtil.kt +++ b/app/src/main/java/io/github/aedev/flow/data/download/DownloadUtil.kt @@ -43,7 +43,7 @@ class DownloadUtil ) { companion object { private const val TAG = "DownloadUtil" - private const val CHUNK_LENGTH = 512 * 1024L // 512KB for cache check + private const val CHUNK_LENGTH = 512 * 1024L // per stream request private val URL_RANGE_PARAM_REGEX = Regex("""([?&])range=\d+-\d*(&?)""") } @@ -70,13 +70,18 @@ class DownloadUtil .setCacheWriteDataSinkFactory(null) .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + // A cache read that outlives its span (evicted meanwhile) still holds the music:// uri. + val networkFactory = + ResolvingDataSource.Factory(DefaultDataSource.Factory(context, OkHttpDataSource.Factory(okHttpClient))) { dataSpec -> + if (dataSpec.uri.scheme == LocalCopyDataSource.MUSIC_SCHEME) resolveStream(dataSpec) else dataSpec + } + val musicCacheFactory = CacheDataSource .Factory() .setCache(musicCache) - .setUpstreamDataSourceFactory( - DefaultDataSource.Factory(context, OkHttpDataSource.Factory(okHttpClient)), - ).setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + .setUpstreamDataSourceFactory(networkFactory) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) val cachedDataSourceFactory = downloadCacheFactory @@ -90,10 +95,14 @@ class DownloadUtil val mediaId = dataSpec.key ?: error("No media id (key) in dataSpec") + // A cache hit is bounded to the bytes actually held: an open-ended read would make + // the cache fetch the rest itself, and record the end of that fetch as the song's + // length. ChunkStitchingDataSource opens the next piece where this one stops. try { - if (downloadCache.isCached(mediaId, dataSpec.position, maxOf(dataSpec.length, 1))) { + val span = downloadCache.cachedSpan(mediaId, dataSpec) + if (span > 0) { Log.d(TAG, "[Player] Serving from downloadCache: $mediaId") - return@Factory dataSpec + return@Factory dataSpec.subrange(0, span) } } catch (e: Exception) { Log.w(TAG, "[Player] downloadCache check error for $mediaId", e) @@ -104,39 +113,52 @@ class DownloadUtil } try { - if (musicCache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH)) { + val span = musicCache.cachedSpan(mediaId, dataSpec) + if (span > 0) { Log.d(TAG, "[Player] Serving from musicCache: $mediaId") - return@Factory dataSpec + return@Factory dataSpec.subrange(0, span) } } catch (e: Exception) { Log.w(TAG, "[Player] musicCache check error for $mediaId", e) } - songUrlCache[mediaId]?.takeIf(::isReusable)?.let { (url, ua, _) -> - Log.d(TAG, "[Player] Using cached URL for $mediaId") - return@Factory buildPlaybackDataSpec(dataSpec, url, ua) - } + resolveStream(dataSpec) + } + val localFirst = LocalCopyDataSource.Factory(DefaultDataSource.Factory(context), resolvingFactory, ::downloadedSongUri) + val refusalRetrying = + RefusedStreamRetryDataSource.Factory(localFirst) { dataSpec, url -> + val mediaId = dataSpec.key + if (mediaId != null) onStreamRefused(mediaId, url) + mediaId != null + } + return ChunkStitchingDataSource.Factory(refusalRetrying) + } - val playbackData = - runBlocking(Dispatchers.IO) { - MusicPlayerUtils.playerResponseForPlayback(mediaId) - }.getOrThrow() + private fun SimpleCache.cachedSpan( + key: String, + dataSpec: DataSpec, + ): Long = getCachedLength(key, dataSpec.position, dataSpec.length).coerceAtLeast(0L) - val streamUrl = playbackData.streamUrl - val userAgent = playbackData.usedClient.userAgent - val expiration = System.currentTimeMillis() + (playbackData.streamExpiresInSeconds - 60) * 1000L + private fun resolveStream(dataSpec: DataSpec): DataSpec { + val mediaId = dataSpec.key ?: error("No media id (key) in dataSpec") + songUrlCache[mediaId]?.takeIf(::isReusable)?.let { (url, ua, _) -> + Log.d(TAG, "[Player] Using cached URL for $mediaId") + return buildPlaybackDataSpec(dataSpec, url, ua) + } - songUrlCache[mediaId] = Triple(streamUrl, userAgent, expiration) - Log.d(TAG, "[Player] Resolved $mediaId via ${playbackData.usedClient.clientName}") + val playbackData = + runBlocking(Dispatchers.IO) { + MusicPlayerUtils.playerResponseForPlayback(mediaId) + }.getOrThrow() - buildPlaybackDataSpec(dataSpec, streamUrl, userAgent) - } - val localFirst = LocalCopyDataSource.Factory(DefaultDataSource.Factory(context), resolvingFactory, ::downloadedSongUri) - return RefusedStreamRetryDataSource.Factory(localFirst) { dataSpec, url -> - val mediaId = dataSpec.key - if (mediaId != null) onStreamRefused(mediaId, url) - mediaId != null - } + val streamUrl = playbackData.streamUrl + val userAgent = playbackData.usedClient.userAgent + val expiration = System.currentTimeMillis() + (playbackData.streamExpiresInSeconds - 60) * 1000L + + songUrlCache[mediaId] = Triple(streamUrl, userAgent, expiration) + Log.d(TAG, "[Player] Resolved $mediaId via ${playbackData.usedClient.clientName}") + + return buildPlaybackDataSpec(dataSpec, streamUrl, userAgent) } private fun onStreamRefused( diff --git a/app/src/main/java/io/github/aedev/flow/data/download/LocalCopyDataSource.kt b/app/src/main/java/io/github/aedev/flow/data/download/LocalCopyDataSource.kt index 17a3919d99..215ebe4bed 100644 --- a/app/src/main/java/io/github/aedev/flow/data/download/LocalCopyDataSource.kt +++ b/app/src/main/java/io/github/aedev/flow/data/download/LocalCopyDataSource.kt @@ -81,7 +81,7 @@ internal class LocalCopyDataSource( override fun createDataSource(): DataSource = LocalCopyDataSource(local.createDataSource(), remote.createDataSource(), localCopyOf) } - private companion object { + internal companion object { const val MUSIC_SCHEME = "music" val DEVICE_SCHEMES = setOf("file", "content", "android.resource") } diff --git a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt index 083dff1d01..1e29ecc63b 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt @@ -99,6 +99,7 @@ class PlayerPreferences( val CONTENT_LANGUAGE = stringPreferencesKey("content_language") val ORIGINAL_TITLES = booleanPreferencesKey("original_titles") val MUSIC_LOUDNESS_NORMALIZATION_ENABLED = booleanPreferencesKey("music_loudness_normalization_enabled") + val MUSIC_AUDIO_OFFLOAD_ENABLED = booleanPreferencesKey("music_audio_offload_enabled") val SKIP_SILENCE_ENABLED = booleanPreferencesKey("skip_silence_enabled") val SPONSOR_BLOCK_ENABLED = booleanPreferencesKey("sponsor_block_enabled") val AUTO_PIP_ENABLED = booleanPreferencesKey("auto_pip_enabled") @@ -1435,6 +1436,18 @@ class PlayerPreferences( } } + val musicAudioOffloadEnabled: Flow = + context.playerPreferencesDataStore.data + .map { preferences -> + preferences[Keys.MUSIC_AUDIO_OFFLOAD_ENABLED] ?: false + } + + suspend fun setMusicAudioOffloadEnabled(enabled: Boolean) { + context.playerPreferencesDataStore.edit { preferences -> + preferences[Keys.MUSIC_AUDIO_OFFLOAD_ENABLED] = enabled + } + } + // Quality preferences val defaultQualityWifi: Flow = context.playerPreferencesDataStore.data diff --git a/app/src/main/java/io/github/aedev/flow/extensions/PlayerExtensions.kt b/app/src/main/java/io/github/aedev/flow/extensions/PlayerExtensions.kt index b6a7501026..f5261be342 100644 --- a/app/src/main/java/io/github/aedev/flow/extensions/PlayerExtensions.kt +++ b/app/src/main/java/io/github/aedev/flow/extensions/PlayerExtensions.kt @@ -18,12 +18,13 @@ fun Player.togglePlayPause() { } fun Player.toggleRepeatMode() { - repeatMode = when (repeatMode) { - REPEAT_MODE_OFF -> REPEAT_MODE_ALL - REPEAT_MODE_ALL -> REPEAT_MODE_ONE - REPEAT_MODE_ONE -> REPEAT_MODE_OFF - else -> REPEAT_MODE_OFF - } + repeatMode = + when (repeatMode) { + REPEAT_MODE_OFF -> REPEAT_MODE_ALL + REPEAT_MODE_ALL -> REPEAT_MODE_ONE + REPEAT_MODE_ONE -> REPEAT_MODE_OFF + else -> REPEAT_MODE_OFF + } } fun Player.getQueueWindows(): List { @@ -49,11 +50,12 @@ fun Player.getQueueWindows(): List { } } if (firstMediaItemIndex != C.INDEX_UNSET && queue.size < queueSize) { - firstMediaItemIndex = timeline.getPreviousWindowIndex( - firstMediaItemIndex, - REPEAT_MODE_OFF, - shuffleModeEnabled - ) + firstMediaItemIndex = + timeline.getPreviousWindowIndex( + firstMediaItemIndex, + REPEAT_MODE_OFF, + shuffleModeEnabled, + ) if (firstMediaItemIndex != C.INDEX_UNSET) { queue.addFirst(timeline.getWindow(firstMediaItemIndex, Timeline.Window())) } @@ -69,11 +71,12 @@ fun Player.getCurrentQueueIndex(): Int { var index = 0 var currentIndex = currentMediaItemIndex while (currentIndex != C.INDEX_UNSET) { - currentIndex = currentTimeline.getPreviousWindowIndex( - currentIndex, - REPEAT_MODE_OFF, - shuffleModeEnabled - ) + currentIndex = + currentTimeline.getPreviousWindowIndex( + currentIndex, + REPEAT_MODE_OFF, + shuffleModeEnabled, + ) if (currentIndex != C.INDEX_UNSET) { index++ } @@ -81,15 +84,14 @@ fun Player.getCurrentQueueIndex(): Int { return index } - val Player.mediaItems: List - get() = object : AbstractList() { - override val size: Int - get() = mediaItemCount - - override fun get(index: Int): MediaItem = getMediaItemAt(index) - } + get() = + object : AbstractList() { + override val size: Int + get() = mediaItemCount + override fun get(index: Int): MediaItem = getMediaItemAt(index) + } fun Player.findNextMediaItemById(mediaId: String): MediaItem? { for (i in currentMediaItemIndex until mediaItemCount) { @@ -100,20 +102,23 @@ fun Player.findNextMediaItemById(mediaId: String): MediaItem? { return null } +/** Songs advance gaplessly, so a device that cannot keep that while offloaded plays them in PCM. */ fun Player.setOffloadEnabled(enabled: Boolean) { - trackSelectionParameters = trackSelectionParameters.buildUpon() - .setAudioOffloadPreferences( - TrackSelectionParameters.AudioOffloadPreferences - .Builder() - .setAudioOffloadMode( - if (enabled) { - TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED - } else { - TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED - } - ) - .build() - ).build() + trackSelectionParameters = + trackSelectionParameters + .buildUpon() + .setAudioOffloadPreferences( + TrackSelectionParameters.AudioOffloadPreferences + .Builder() + .setAudioOffloadMode( + if (enabled) { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED + } else { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED + }, + ).setIsGaplessSupportRequired(true) + .build(), + ).build() } val Player.isPlayable: Boolean diff --git a/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt b/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt index 4f902bda8e..198f638276 100644 --- a/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt @@ -99,6 +99,12 @@ object EnhancedMusicPlayerManager { /** The restored track and where it stopped, used once by the next play of that same track. */ private var resumePosition: Pair? = null + + /** A song started before the controller connected, such as a link opening the app; sent on connect. */ + private var playBeforeConnect: (() -> Unit)? = null + + /** Counts started queues, so a saved queue read meanwhile knows a newer one replaced it. */ + private var queueStarts = 0 private var audioSettingsPersistence: AudioSettingsPersistence? = null // Audio Settings State @@ -263,8 +269,15 @@ object EnhancedMusicPlayerManager { if (controller != null) { setupPlayerListener(controller) + val startedBeforeConnect = playBeforeConnect + playBeforeConnect = null scope.launch { - restoreSavedQueue() + if (startedBeforeConnect != null) { + Log.d("EnhancedMusicPlayer", "Starting the song asked for before the controller connected") + startedBeforeConnect() + } else { + restoreSavedQueue() + } restoreAudioSettings() } @@ -626,6 +639,10 @@ object EnhancedMusicPlayerManager { sourceName: String? = null, localUriOverrides: Map = emptyMap(), ) { + queueStarts++ + if (player == null && controllerFuture != null) { + playBeforeConnect = { playTrack(track, audioUrl, queue, startIndex, startPositionMs, sourceName, localUriOverrides) } + } val restoredStart = resumePosition?.takeIf { (id, _) -> id == track.videoId && startPositionMs == 0L }?.second resumePosition = null player?.stop() @@ -787,7 +804,12 @@ object EnhancedMusicPlayerManager { */ private suspend fun restoreSavedQueue() { try { + val starts = queueStarts val restored = queuePersistence?.restoreQueue()?.let(::restoredMusicQueue) ?: return + if (queueStarts != starts) { + Log.d("EnhancedMusicPlayer", "Saved queue skipped: a newer queue started while it was read") + return + } val controller = player val loadedItem = controller?.currentMediaItem // The system media controls may already have resumed this queue in the service. diff --git a/app/src/main/java/io/github/aedev/flow/player/MusicLoadErrorPolicy.kt b/app/src/main/java/io/github/aedev/flow/player/MusicLoadErrorPolicy.kt index 42bb69a796..9edad67ca1 100644 --- a/app/src/main/java/io/github/aedev/flow/player/MusicLoadErrorPolicy.kt +++ b/app/src/main/java/io/github/aedev/flow/player/MusicLoadErrorPolicy.kt @@ -32,7 +32,7 @@ internal class MusicLoadErrorPolicy( override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long { val error = loadErrorInfo.exception if (isUnretryableResolve(error)) return C.TIME_UNSET - val connectionLost = isConnectionLoss(error) || (error is IOException && isOffline()) + val connectionLost = countsAsLostConnection(error, loadErrorInfo.loadEventInfo.uri.scheme, isOffline) return retryDelayMs(loadErrorInfo.errorCount, connectionLost) ?: super.getRetryDelayMsFor(loadErrorInfo) } @@ -74,6 +74,16 @@ internal class MusicLoadErrorPolicy( ?.block ?.takeIf { it in ViewerBlocks } + /** + * Offline, any read error of a streamed song is the dropped connection. A file on the device + * does not come back with the network, so it fails as any other error would. + */ + fun countsAsLostConnection( + error: Throwable, + uriScheme: String?, + isOffline: () -> Boolean, + ): Boolean = isConnectionLoss(error) || (error is IOException && uriScheme !in DeviceSchemes && isOffline()) + fun isConnectionLoss(error: Throwable): Boolean = causes(error).any { it is UnknownHostException || @@ -87,6 +97,8 @@ internal class MusicLoadErrorPolicy( private const val MAX_CAUSE_DEPTH = 8 + private val DeviceSchemes = setOf("file", "content", "android.resource") + private val ViewerBlocks = setOf(PlaybackBlock.REGION, PlaybackBlock.RATE_LIMITED, PlaybackBlock.MEMBERS_OR_PAID) } } diff --git a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt index a2db4f9a74..14cbb49aa5 100644 --- a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt +++ b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt @@ -412,6 +412,9 @@ class Media3MusicService : MediaLibraryService() { @OptIn(UnstableApi::class) private fun observeOffload() { + lifecycleScope.launch { + prefs.musicAudioOffloadEnabled.collect { enabled -> updateOffload { it.copy(enabledByUser = enabled) } } + } player.addAudioOffloadListener( object : ExoPlayer.AudioOffloadListener { override fun onOffloadedPlayback(isOffloadedPlayback: Boolean) { @@ -1744,14 +1747,28 @@ class Media3MusicService : MediaLibraryService() { val result = SettableFuture.create() lifecycleScope.launch { val restored = withContext(Dispatchers.IO) { queuePersistence.restoreQueue() }?.let(::restoredMusicQueue) - if (restored == null) { - result.setException(IllegalStateException("No saved music queue to resume")) - } else { - // A queue saved shuffled is already in its shuffled order. - applyingSavedShuffle = true - player.shuffleModeEnabled = restored.shuffleEnabled - applyingSavedShuffle = false - result.set(musicResumptionItems(restored)) + when { + restored == null -> { + result.setException(IllegalStateException("No saved music queue to resume")) + } + + // A play on an empty player asked for this, but a song the app or a link started + // while the saved queue was read is newer, and resuming would replace it. + isForPlayback && player.currentMediaItem != null -> { + Log.d(TAG, "Resumption skipped: ${player.currentMediaItem?.mediaId} started while the saved queue was read") + result.setException(IllegalStateException("A newer queue started while resuming")) + } + + else -> { + // A queue saved shuffled is already in its shuffled order. System UI asking for + // the recent item only shows it, so the live player's flag stays as it is. + if (isForPlayback) { + applyingSavedShuffle = true + player.shuffleModeEnabled = restored.shuffleEnabled + applyingSavedShuffle = false + } + result.set(musicResumptionItems(restored)) + } } } return result diff --git a/app/src/main/java/io/github/aedev/flow/service/MusicOffloadState.kt b/app/src/main/java/io/github/aedev/flow/service/MusicOffloadState.kt index 488d670ca0..974dfb1eef 100644 --- a/app/src/main/java/io/github/aedev/flow/service/MusicOffloadState.kt +++ b/app/src/main/java/io/github/aedev/flow/service/MusicOffloadState.kt @@ -3,17 +3,20 @@ package io.github.aedev.flow.service import kotlin.math.abs /** - * What decides whether the music player may use audio offload. Offloaded audio skips every audio - * processor and may not apply a speed or pitch, so offload stays off while the sound is changed. A - * paused offloaded track also keeps the audio hardware out of standby, and the system holds its - * AudioOffload wake lock until the player lets that track go, so a pause that lasts gives offload up. + * What decides whether the music player may use audio offload. It is the listener's choice first, + * since some audio hardware takes offloaded tracks and plays silence or never starts (#1417, #1422). + * Offloaded audio skips every audio processor and may not apply a speed or pitch, so offload stays + * off while the sound is changed. A paused offloaded track also keeps the audio hardware out of + * standby, and the system holds its AudioOffload wake lock until the player lets that track go, so a + * pause that lasts gives offload up. */ internal data class MusicOffloadState( + val enabledByUser: Boolean = false, val processing: Boolean = false, val playbackChanged: Boolean = false, val pausedLong: Boolean = false, ) { - val allowed: Boolean get() = !processing && !playbackChanged && !pausedLong + val allowed: Boolean get() = enabledByUser && !processing && !playbackChanged && !pausedLong companion object { // Sonic's own threshold: a speed saved from a slider can read 0.99999994, which plays untouched. diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/sheet/AudioSettingsSheet.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/sheet/AudioSettingsSheet.kt index 1cab1baedc..173d5c4657 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/sheet/AudioSettingsSheet.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/sheet/AudioSettingsSheet.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BatterySaver import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.ExperimentalMaterial3Api @@ -42,10 +43,11 @@ import kotlinx.coroutines.launch import kotlin.math.roundToInt private const val TEMPO_ROWS = 3 +private const val OUTPUT_ROWS = 2 private const val PITCH_STEPS = 23 private val SliderRowPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp) -/** The music player's audio settings: the equalizer panel, tempo and pitch, and loudness normalisation. */ +/** The music player's audio settings: the equalizer panel, tempo and pitch, loudness normalisation and offload. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun AudioSettingsSheet(onDismiss: () -> Unit) { @@ -55,6 +57,7 @@ fun AudioSettingsSheet(onDismiss: () -> Unit) { val context = LocalContext.current val preferences = remember { PlayerPreferences(context) } val normalizationEnabled by preferences.musicLoudnessNormalizationEnabled.collectAsState(initial = true) + val offloadEnabled by preferences.musicAudioOffloadEnabled.collectAsState(initial = false) val customSpeedsEnabled by preferences.customSpeedsEnabled.collectAsState(initial = false) val customSpeedPresetsRaw by preferences.customSpeedPresets.collectAsState(initial = "") val scope = rememberCoroutineScope() @@ -127,7 +130,15 @@ fun AudioSettingsSheet(onDismiss: () -> Unit) { checked = normalizationEnabled, onCheckedChange = { enabled -> scope.launch { preferences.setMusicLoudnessNormalizationEnabled(enabled) } }, leadingIcon = Icons.Filled.GraphicEq, - shape = flowRowGroupShape(0, 1), + shape = flowRowGroupShape(0, OUTPUT_ROWS), + ) + FlowSwitchRow( + title = stringResource(R.string.music_audio_offload_title), + supportingText = stringResource(R.string.music_audio_offload_desc), + checked = offloadEnabled, + onCheckedChange = { enabled -> scope.launch { preferences.setMusicAudioOffloadEnabled(enabled) } }, + leadingIcon = Icons.Filled.BatterySaver, + shape = flowRowGroupShape(1, OUTPUT_ROWS), ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d162f14fa7..8cd2d79342 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2164,6 +2164,8 @@ Content country Normalize volume Level every track to the YouTube Music loudness target + Audio offload + Saves battery. Some phones play silence with it on Other versions Blocked artists No blocked artists diff --git a/app/src/test/java/io/github/aedev/flow/data/download/ChunkStitchingDataSourceTest.kt b/app/src/test/java/io/github/aedev/flow/data/download/ChunkStitchingDataSourceTest.kt new file mode 100644 index 0000000000..c8eaf286b8 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/data/download/ChunkStitchingDataSourceTest.kt @@ -0,0 +1,118 @@ +package io.github.aedev.flow.data.download + +import android.net.Uri +import androidx.media3.common.C +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import java.io.IOException + +/** Plain JVM: the data source reads only a URI's scheme, so mocks stand in for the framework's Uri. */ +class ChunkStitchingDataSourceTest { + /** Serves [song] in pieces of at most [pieceLength] bytes per open, as the resolver's chunks do. */ + private class ChunkedSong( + private val song: ByteArray, + private val pieceLength: Int, + private val openedUri: Uri, + private val failFrom: Long = Long.MAX_VALUE, + ) : DataSource { + val openedAt = mutableListOf() + private var position = 0L + private var remaining = 0L + + override fun addTransferListener(transferListener: TransferListener) = Unit + + override fun open(dataSpec: DataSpec): Long { + if (dataSpec.position >= failFrom) throw IOException("offline") + openedAt += dataSpec.position + position = dataSpec.position + remaining = minOf(pieceLength.toLong(), song.size - position) + return remaining + } + + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { + if (remaining == 0L) return C.RESULT_END_OF_INPUT + val count = minOf(length.toLong(), remaining).toInt() + song.copyInto(buffer, offset, position.toInt(), position.toInt() + count) + position += count + remaining -= count + return count + } + + override fun getUri(): Uri = openedUri + + override fun close() = Unit + } + + private val song = ByteArray(25) { it.toByte() } + + private fun uri(scheme: String): Uri = mockk { every { this@mockk.scheme } returns scheme } + + private fun spec( + scheme: String = "music", + length: Long = C.LENGTH_UNSET.toLong(), + ) = DataSpec + .Builder() + .setUri(uri(scheme)) + .setKey("abc") + .setLength(length) + .build() + + private fun DataSource.readAll(): ByteArray { + val out = mutableListOf() + val buffer = ByteArray(4) + while (true) { + val read = read(buffer, 0, buffer.size) + if (read == C.RESULT_END_OF_INPUT) return out.toByteArray() + out += buffer.take(read) + } + } + + @Test + fun `a streamed song reads on across its chunks as one stream`() { + val upstream = ChunkedSong(song, pieceLength = 10, openedUri = uri("https")) + val source = ChunkStitchingDataSource(upstream) + + assertThat(source.open(spec())).isEqualTo(C.LENGTH_UNSET.toLong()) + assertThat(source.readAll()).isEqualTo(song) + assertThat(upstream.openedAt).containsExactly(0L, 10L, 20L, 25L).inOrder() + } + + @Test + fun `a failed open past the last chunk ends the song where it stands`() { + val upstream = ChunkedSong(song, pieceLength = 10, openedUri = uri("music"), failFrom = 25) + val source = ChunkStitchingDataSource(upstream) + + source.open(spec()) + + assertThat(source.readAll()).isEqualTo(song) + } + + @Test + fun `a song on the device is read as it is`() { + val upstream = ChunkedSong(song, pieceLength = 10, openedUri = uri("file")) + val source = ChunkStitchingDataSource(upstream) + + assertThat(source.open(spec())).isEqualTo(10L) + assertThat(source.readAll()).hasLength(10) + assertThat(upstream.openedAt).containsExactly(0L) + } + + @Test + fun `a bounded request is not carried on`() { + val upstream = ChunkedSong(song, pieceLength = 10, openedUri = uri("https")) + val source = ChunkStitchingDataSource(upstream) + + assertThat(source.open(spec(length = 10))).isEqualTo(10L) + assertThat(source.readAll()).hasLength(10) + assertThat(upstream.openedAt).containsExactly(0L) + } +} diff --git a/app/src/test/java/io/github/aedev/flow/player/MusicLoadErrorPolicyTest.kt b/app/src/test/java/io/github/aedev/flow/player/MusicLoadErrorPolicyTest.kt index 5ac5448b23..387b4f45b1 100644 --- a/app/src/test/java/io/github/aedev/flow/player/MusicLoadErrorPolicyTest.kt +++ b/app/src/test/java/io/github/aedev/flow/player/MusicLoadErrorPolicyTest.kt @@ -35,6 +35,19 @@ class MusicLoadErrorPolicyTest { assertThat(MusicLoadErrorPolicy.isConnectionLoss(IOException("Response code: 403"))).isFalse() } + @Test + fun `offline, a streamed song's read error waits for the network`() { + assertThat(MusicLoadErrorPolicy.countsAsLostConnection(IOException("Malformed URL"), "https") { true }).isTrue() + assertThat(MusicLoadErrorPolicy.countsAsLostConnection(IOException("Malformed URL"), "music") { true }).isTrue() + assertThat(MusicLoadErrorPolicy.countsAsLostConnection(IOException("Response code: 403"), "https") { false }).isFalse() + } + + @Test + fun `offline, a file on the device that cannot be read is not waited for`() { + assertThat(MusicLoadErrorPolicy.countsAsLostConnection(IOException("EACCES"), "content") { true }).isFalse() + assertThat(MusicLoadErrorPolicy.countsAsLostConnection(IOException("ENOENT"), "file") { true }).isFalse() + } + @Test fun `a song gone from every client or a resolve out of time is not retried by the loader`() { assertThat(MusicLoadErrorPolicy.isUnretryableResolve(IOException(VideoUnavailableException("abc")))).isTrue() diff --git a/app/src/test/java/io/github/aedev/flow/service/MusicOffloadStateTest.kt b/app/src/test/java/io/github/aedev/flow/service/MusicOffloadStateTest.kt index e7f0ca15c0..301e42ff19 100644 --- a/app/src/test/java/io/github/aedev/flow/service/MusicOffloadStateTest.kt +++ b/app/src/test/java/io/github/aedev/flow/service/MusicOffloadStateTest.kt @@ -4,24 +4,31 @@ import com.google.common.truth.Truth.assertThat import org.junit.Test class MusicOffloadStateTest { + private val optedIn = MusicOffloadState(enabledByUser = true) + + @Test + fun `offload stays off until the listener turns it on`() { + assertThat(MusicOffloadState().allowed).isFalse() + } + @Test - fun `untouched sound while playing may offload`() { - assertThat(MusicOffloadState().allowed).isTrue() + fun `untouched sound while playing may offload once turned on`() { + assertThat(optedIn.allowed).isTrue() } @Test fun `an equalizer that changes the sound keeps offload off`() { - assertThat(MusicOffloadState(processing = true).allowed).isFalse() + assertThat(optedIn.copy(processing = true).allowed).isFalse() } @Test fun `a changed speed or pitch keeps offload off`() { - assertThat(MusicOffloadState(playbackChanged = true).allowed).isFalse() + assertThat(optedIn.copy(playbackChanged = true).allowed).isFalse() } @Test fun `a long pause gives offload up until playback resumes`() { - val paused = MusicOffloadState(pausedLong = true) + val paused = optedIn.copy(pausedLong = true) assertThat(paused.allowed).isFalse() assertThat(paused.copy(pausedLong = false).allowed).isTrue()