Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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<String, List<String>> = 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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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*(&?)""")
}

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -1435,6 +1436,18 @@ class PlayerPreferences(
}
}

val musicAudioOffloadEnabled: Flow<Boolean> =
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<VideoQuality> =
context.playerPreferencesDataStore.data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Timeline.Window> {
Expand All @@ -49,11 +50,12 @@ fun Player.getQueueWindows(): List<Timeline.Window> {
}
}
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()))
}
Expand All @@ -69,27 +71,27 @@ 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++
}
}
return index
}


val Player.mediaItems: List<MediaItem>
get() = object : AbstractList<MediaItem>() {
override val size: Int
get() = mediaItemCount

override fun get(index: Int): MediaItem = getMediaItemAt(index)
}
get() =
object : AbstractList<MediaItem>() {
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) {
Expand All @@ -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
Expand Down
Loading
Loading