From 9d09dd3ffad5dfd516c455ba77583f10d9390fe4 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:18:12 +0300 Subject: [PATCH 01/14] build(deps): bump DataStore to 1.1.7 for the first-read race --- AGENTS.md | 2 +- gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e5d34a48ea..5da916beb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,7 @@ Before writing any component, effect, animation, formatter, parser, scheduler, o | Paging | Paging 3 (`androidx.paging`, already used by search and channel) | New hand-rolled page cursors | | Playback | Media3 1.11.0 — exoplayer, hls, dash, session, datasource-okhttp | Custom player wiring, manifest handling, or media notifications | | Background work | WorkManager 2.11.2 | Custom threads, timers, alarms, or wakelocks | -| Persistence | Room 2.8.4, DataStore Preferences 1.1.1 | Hand-rolled file or SharedPreferences layers | +| Persistence | Room 2.8.4, DataStore Preferences 1.1.7 | Hand-rolled file or SharedPreferences layers | | JSON | `kotlinx.serialization` (103 files) | Gson for new code — it is kept only for legacy DTOs and carries an R8 reflection hazard | | HTTP | OkHttp 5.4.0 / Ktor 3.5.2 | Raw sockets or `HttpURLConnection` | | Dates and times | `java.time` — **core library desugaring is enabled, minSdk 26**, so all of it is available | `SimpleDateFormat`, `Calendar`, or manual millisecond arithmetic | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8a3e9dac22..c13f476167 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ kotlinxSerialization = "1.11.0" gson = "2.14.0" media3 = "1.11.0" room = "2.8.4" -datastore = "1.1.1" # 1.2.1 breaks DataStore unit tests on Windows (renameTo on repeat edits) +datastore = "1.1.7" # 1.2.1 breaks DataStore unit tests on Windows (renameTo on repeat edits) glance = "1.2.0" graphicsShapes = "1.1.0" coroutines = "1.11.0" From a6f09f41f01ad92259c0cca33b6cecea71fc3241 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:27:38 +0300 Subject: [PATCH 02/14] fix(datastore): fall back to defaults when a preferences read fails --- .../aedev/flow/data/local/SafeDataStore.kt | 59 ++++++++++++-- .../music/artwork/AnimatedArtworkCache.kt | 4 +- .../flow/data/stats/RecapImageResolver.kt | 4 +- .../aedev/flow/data/stats/RecapReadiness.kt | 4 +- .../widget/core/refresh/WidgetContentSync.kt | 4 +- .../flow/widget/core/state/NowPlayingState.kt | 4 +- .../core/theme/WidgetThemeSignatureStore.kt | 4 +- .../flow/data/local/ReadSafeDataStoreTest.kt | 78 +++++++++++++++++++ 8 files changed, 141 insertions(+), 20 deletions(-) create mode 100644 app/src/test/java/io/github/aedev/flow/data/local/ReadSafeDataStoreTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/data/local/SafeDataStore.kt b/app/src/main/java/io/github/aedev/flow/data/local/SafeDataStore.kt index b91312dac0..68dd470972 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/SafeDataStore.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/SafeDataStore.kt @@ -7,15 +7,58 @@ import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.retryWhen +import java.io.IOException import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty private const val SAFE_DATA_STORE_TAG = "SafeDataStore" +private const val READ_RETRY_ATTEMPTS = 3 +private const val READ_RETRY_DELAY_MS = 100L -fun safePreferencesDataStore(name: String): ReadOnlyProperty> = - preferencesDataStore( - name = name, - corruptionHandler = ReplaceFileCorruptionHandler { corruption -> - Log.w(SAFE_DATA_STORE_TAG, "Resetting corrupted preferences DataStore: $name", corruption) - emptyPreferences() - } - ) +fun safePreferencesDataStore(name: String): ReadOnlyProperty> { + val delegate = + preferencesDataStore( + name = name, + corruptionHandler = + ReplaceFileCorruptionHandler { corruption -> + Log.w(SAFE_DATA_STORE_TAG, "Resetting corrupted preferences DataStore: $name", corruption) + emptyPreferences() + }, + ) + var wrapped: DataStore? = null + return object : ReadOnlyProperty> { + override fun getValue( + thisRef: Context, + property: KProperty<*>, + ): DataStore = + wrapped ?: synchronized(this) { + wrapped ?: ReadSafeDataStore(name, delegate.getValue(thisRef, property)).also { wrapped = it } + } + } +} + +internal class ReadSafeDataStore( + private val name: String, + private val delegate: DataStore, + private val retryDelayMs: Long = READ_RETRY_DELAY_MS, +) : DataStore { + // A failed read must not crash the collector. Retrying first keeps observers subscribed to + // later writes; only a read that keeps failing falls back to defaults and ends the stream. + override val data: Flow = + delegate.data + .retryWhen { cause, attempt -> + val retry = cause is IOException && attempt < READ_RETRY_ATTEMPTS + if (retry) delay(retryDelayMs * (attempt + 1)) + retry + }.catch { cause -> + if (cause !is IOException) throw cause + Log.w(SAFE_DATA_STORE_TAG, "Reading preferences DataStore failed, using defaults: $name", cause) + emit(emptyPreferences()) + } + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences = delegate.updateData(transform) +} diff --git a/app/src/main/java/io/github/aedev/flow/data/music/artwork/AnimatedArtworkCache.kt b/app/src/main/java/io/github/aedev/flow/data/music/artwork/AnimatedArtworkCache.kt index 6d70eaf29a..f0d74a50ea 100644 --- a/app/src/main/java/io/github/aedev/flow/data/music/artwork/AnimatedArtworkCache.kt +++ b/app/src/main/java/io/github/aedev/flow/data/music/artwork/AnimatedArtworkCache.kt @@ -3,7 +3,7 @@ package io.github.aedev.flow.data.music.artwork import android.content.Context import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore +import io.github.aedev.flow.data.local.safePreferencesDataStore import kotlinx.coroutines.flow.first /** A lookup answered before: the loop, or null when the album has none, and when it was asked. */ @@ -23,7 +23,7 @@ internal interface AnimatedArtworkCache { suspend fun remove(key: String) } -private val Context.animatedArtworkStore by preferencesDataStore(name = "animated_artwork") +private val Context.animatedArtworkStore by safePreferencesDataStore(name = "animated_artwork") /** Answers kept across launches, so the service is asked about an album once in [MAX_ENTRIES]. */ internal class DataStoreAnimatedArtworkCache( diff --git a/app/src/main/java/io/github/aedev/flow/data/stats/RecapImageResolver.kt b/app/src/main/java/io/github/aedev/flow/data/stats/RecapImageResolver.kt index 17135be896..4bef8b852b 100644 --- a/app/src/main/java/io/github/aedev/flow/data/stats/RecapImageResolver.kt +++ b/app/src/main/java/io/github/aedev/flow/data/stats/RecapImageResolver.kt @@ -4,9 +4,9 @@ import android.content.Context import android.util.Log import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import io.github.aedev.flow.data.local.SubscriptionRepository +import io.github.aedev.flow.data.local.safePreferencesDataStore import io.github.aedev.flow.data.recommendation.music.MusicBrainEngine import io.github.aedev.flow.data.repository.YouTubeRepository import io.github.aedev.flow.di.NetworkIoDispatcher @@ -23,7 +23,7 @@ import java.util.Collections import javax.inject.Inject import javax.inject.Singleton -private val Context.recapImages by preferencesDataStore(name = "flow_recap_images") +private val Context.recapImages by safePreferencesDataStore(name = "flow_recap_images") private val IMAGES = stringPreferencesKey("images") /** diff --git a/app/src/main/java/io/github/aedev/flow/data/stats/RecapReadiness.kt b/app/src/main/java/io/github/aedev/flow/data/stats/RecapReadiness.kt index 60b4365cf3..c0a725915d 100644 --- a/app/src/main/java/io/github/aedev/flow/data/stats/RecapReadiness.kt +++ b/app/src/main/java/io/github/aedev/flow/data/stats/RecapReadiness.kt @@ -3,15 +3,15 @@ package io.github.aedev.flow.data.stats import android.content.Context import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.aedev.flow.data.local.safePreferencesDataStore import io.github.aedev.flow.data.recommendation.music.MusicBrainEngine import kotlinx.coroutines.flow.first import java.time.YearMonth import javax.inject.Inject import javax.inject.Singleton -private val Context.recapPreferences by preferencesDataStore(name = "flow_recap") +private val Context.recapPreferences by safePreferencesDataStore(name = "flow_recap") private val LAST_SHOWN = stringPreferencesKey("last_shown_month") /** diff --git a/app/src/main/java/io/github/aedev/flow/widget/core/refresh/WidgetContentSync.kt b/app/src/main/java/io/github/aedev/flow/widget/core/refresh/WidgetContentSync.kt index 260ab8e97c..a56fe1b023 100644 --- a/app/src/main/java/io/github/aedev/flow/widget/core/refresh/WidgetContentSync.kt +++ b/app/src/main/java/io/github/aedev/flow/widget/core/refresh/WidgetContentSync.kt @@ -4,10 +4,10 @@ import android.content.Context import android.util.Log import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.updateAll import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.aedev.flow.data.local.safePreferencesDataStore import io.github.aedev.flow.widget.downloads.DownloadsSource import io.github.aedev.flow.widget.onrepeat.OnRepeatSource import io.github.aedev.flow.widget.playlist.PlaylistWidgetSource @@ -36,7 +36,7 @@ import kotlinx.coroutines.sync.withLock import javax.inject.Inject import javax.inject.Singleton -private val Context.widgetContentStore by preferencesDataStore(name = "widget_content") +private val Context.widgetContentStore by safePreferencesDataStore(name = "widget_content") /** * Re-renders a content widget when what it draws has changed, and only while one is placed. diff --git a/app/src/main/java/io/github/aedev/flow/widget/core/state/NowPlayingState.kt b/app/src/main/java/io/github/aedev/flow/widget/core/state/NowPlayingState.kt index de3acfd0eb..0465b7eff7 100644 --- a/app/src/main/java/io/github/aedev/flow/widget/core/state/NowPlayingState.kt +++ b/app/src/main/java/io/github/aedev/flow/widget/core/state/NowPlayingState.kt @@ -6,7 +6,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore +import io.github.aedev.flow.data.local.safePreferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -29,7 +29,7 @@ data class NowPlayingSnapshot( val speed: Float = 1f, ) -private val Context.nowPlayingWidgetStore by preferencesDataStore(name = "now_playing_widget") +private val Context.nowPlayingWidgetStore by safePreferencesDataStore(name = "now_playing_widget") private object Keys { val MEDIA_ID = stringPreferencesKey("media_id") diff --git a/app/src/main/java/io/github/aedev/flow/widget/core/theme/WidgetThemeSignatureStore.kt b/app/src/main/java/io/github/aedev/flow/widget/core/theme/WidgetThemeSignatureStore.kt index 07723e996f..e3b15bf2b8 100644 --- a/app/src/main/java/io/github/aedev/flow/widget/core/theme/WidgetThemeSignatureStore.kt +++ b/app/src/main/java/io/github/aedev/flow/widget/core/theme/WidgetThemeSignatureStore.kt @@ -3,11 +3,11 @@ package io.github.aedev.flow.widget.core.theme import android.content.Context import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore +import io.github.aedev.flow.data.local.safePreferencesDataStore import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -private val Context.widgetThemeStore by preferencesDataStore(name = "widget_theme") +private val Context.widgetThemeStore by safePreferencesDataStore(name = "widget_theme") private val LAST_SIGNATURE = stringPreferencesKey("last_signature") diff --git a/app/src/test/java/io/github/aedev/flow/data/local/ReadSafeDataStoreTest.kt b/app/src/test/java/io/github/aedev/flow/data/local/ReadSafeDataStoreTest.kt new file mode 100644 index 0000000000..7080023ca0 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/data/local/ReadSafeDataStoreTest.kt @@ -0,0 +1,78 @@ +package io.github.aedev.flow.data.local + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.mutablePreferencesOf +import androidx.datastore.preferences.core.stringPreferencesKey +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.FileNotFoundException +import java.io.IOException + +class ReadSafeDataStoreTest { + private val key = stringPreferencesKey("k") + private val stored = mutablePreferencesOf(key to "v") + + private class FailingStore( + private val failures: Int, + private val error: Throwable, + private val value: Preferences, + ) : DataStore { + var reads = 0 + + override val data: Flow = + flow { + reads++ + if (reads <= failures) throw error + emit(value) + } + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences = transform(value) + } + + @Test + fun `a read that fails once is retried and returns the stored value`() = + runTest { + val delegate = FailingStore(1, FileNotFoundException("datastore/player_preferences.preferences_pb"), stored) + val store = ReadSafeDataStore("player_preferences", delegate, retryDelayMs = 0) + + assertThat(store.data.first()[key]).isEqualTo("v") + assertThat(delegate.reads).isEqualTo(2) + } + + @Test + fun `a read that keeps failing falls back to defaults instead of throwing`() = + runTest { + val delegate = FailingStore(Int.MAX_VALUE, IOException("disk"), stored) + val store = ReadSafeDataStore("search_history", delegate, retryDelayMs = 0) + + val emitted = store.data.toList() + + assertThat(emitted).hasSize(1) + assertThat(emitted.single().asMap()).isEmpty() + assertThat(delegate.reads).isEqualTo(4) + } + + @Test(expected = IllegalStateException::class) + fun `errors that are not IO still propagate`() = + runTest { + val delegate = FailingStore(1, IllegalStateException("bug"), stored) + ReadSafeDataStore("x", delegate, retryDelayMs = 0).data.first() + } + + @Test + fun `writes go straight to the delegate`() = + runTest { + val delegate = FailingStore(0, IOException(), stored) + val store = ReadSafeDataStore("x", delegate, retryDelayMs = 0) + + val updated = store.updateData { it.toMutablePreferences().apply { set(key, "w") } } + + assertThat(updated[key]).isEqualTo("w") + } +} From d2a0fb1386cb88070f0f87383ef3806d6f5a7f83 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:31:14 +0300 Subject: [PATCH 03/14] fix(crash): clear a crash report once it has been shown --- .../main/java/io/github/aedev/flow/MainActivity.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/MainActivity.kt b/app/src/main/java/io/github/aedev/flow/MainActivity.kt index 41bac3b6ca..62f3b93014 100644 --- a/app/src/main/java/io/github/aedev/flow/MainActivity.kt +++ b/app/src/main/java/io/github/aedev/flow/MainActivity.kt @@ -231,9 +231,14 @@ class MainActivity : ComponentActivity() { SideEffect { cachedAppUiRoot = appUiRoot } // Check for a crash that happened last session. - // If found, show the CrashReportScreen instead of the normal UI. + // If found, show the CrashReportScreen instead of the normal UI. It is cleared as soon + // as it is read, so leaving the screen any way shows it once, not on every launch. var pendingCrashLog by remember { - mutableStateOf(FlowCrashHandler.getLastCrash(applicationContext)) + mutableStateOf( + FlowCrashHandler.getLastCrash(applicationContext)?.also { + FlowCrashHandler.clearLastCrash(applicationContext) + }, + ) } if (pendingCrashLog != null) { @@ -241,10 +246,7 @@ class MainActivity : ComponentActivity() { FlowTheme(theme.copy(fontFamily = FlowFontFamily)) { CrashReportScreen( report = pendingCrashLog!!, - onContinue = { - FlowCrashHandler.clearLastCrash(applicationContext) - pendingCrashLog = null - }, + onContinue = { pendingCrashLog = null }, ) } return@setContent From 9233342ed0855d6fd86c9550b5f3b665b1b6efdd Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:35:20 +0300 Subject: [PATCH 04/14] fix(ui): light status bar icons over the expanded video player --- app/src/main/java/io/github/aedev/flow/ui/FlowApp.kt | 1 + .../main/java/io/github/aedev/flow/ui/FlowStatusBarStyle.kt | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/aedev/flow/ui/FlowApp.kt b/app/src/main/java/io/github/aedev/flow/ui/FlowApp.kt index d0bd9c412d..377ec97cf4 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/FlowApp.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/FlowApp.kt @@ -328,6 +328,7 @@ fun FlowApp( musicPlayerBackgroundStyle == io.github.aedev.flow.data.local.MusicPlayerBackgroundStyle.DEFAULT || (musicPlayerFollowLightTheme && musicPlayerBackgroundStyle.canTurnLight), isShortsPlayer = isShortsPlayerRoute, + videoPlayerExpanded = playerVisible && playerSheetState.currentValue == PlayerSheetValue.Expanded, heroUnderStatusBar = statusBarHero.heroUnderStatusBar && !musicPlayerSheetState.isExpanded && diff --git a/app/src/main/java/io/github/aedev/flow/ui/FlowStatusBarStyle.kt b/app/src/main/java/io/github/aedev/flow/ui/FlowStatusBarStyle.kt index 7e35ad6774..6d186aed96 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/FlowStatusBarStyle.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/FlowStatusBarStyle.kt @@ -28,6 +28,8 @@ internal fun ApplyStatusBarStyle( isMusicPlayerImmersive: Boolean = false, musicPlayerFollowsTheme: Boolean = false, isShortsPlayer: Boolean = false, + // The expanded video player paints its black ground under the status bar on every theme. + videoPlayerExpanded: Boolean = false, // A page's full-bleed hero is under the status bar, so its icons must be light to read on the photo. heroUnderStatusBar: Boolean = false, ) { @@ -46,7 +48,8 @@ internal fun ApplyStatusBarStyle( SideEffect { val window = activity.window val insetsController = WindowCompat.getInsetsController(window, view) - val shouldDrawBehindStatusBar = isFullscreen || isMusicPlayerImmersive || isShortsPlayer || heroUnderStatusBar + val shouldDrawBehindStatusBar = + isFullscreen || isMusicPlayerImmersive || isShortsPlayer || videoPlayerExpanded || heroUnderStatusBar window.statusBarColor = if (shouldDrawBehindStatusBar) { From 711def83bc1e67cb117b4bc62c0276fb4839c0dc Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:43:17 +0300 Subject: [PATCH 05/14] fix(audio): announce audio sessions when ExoPlayer reports them --- .../flow/player/EnhancedPlayerManager.kt | 24 +++--- .../flow/player/audio/AudioFeaturesManager.kt | 8 ++ .../player/audio/AudioSessionAnnouncer.kt | 48 ++++++++++++ .../flow/player/audio/AudioSessionRegistry.kt | 40 ++++++++-- .../flow/player/shorts/ShortsPlayerPool.kt | 12 ++- .../aedev/flow/service/Media3MusicService.kt | 13 +++- .../player/audio/AudioSessionAnnouncerTest.kt | 68 ++++++++++++++++ .../player/audio/AudioSessionRegistryTest.kt | 77 +++++++++++++++++++ 8 files changed, 263 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/io/github/aedev/flow/player/audio/AudioSessionAnnouncer.kt create mode 100644 app/src/test/java/io/github/aedev/flow/player/audio/AudioSessionAnnouncerTest.kt create mode 100644 app/src/test/java/io/github/aedev/flow/player/audio/AudioSessionRegistryTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt b/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt index df59525bc2..4efffdc9c3 100644 --- a/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt @@ -42,6 +42,7 @@ import io.github.aedev.flow.player.analytics.PlaybackAnalyticsLogger import io.github.aedev.flow.player.analytics.RenderedFirstFrameListener import io.github.aedev.flow.player.audio.AudioEffectsEntryPoint import io.github.aedev.flow.player.audio.AudioFeaturesManager +import io.github.aedev.flow.player.audio.AudioSessionAnnouncer import io.github.aedev.flow.player.audio.eq.EqualizerAudioProcessor import io.github.aedev.flow.player.cache.PlayerCacheManager import io.github.aedev.flow.player.config.PlayerConfig @@ -143,7 +144,7 @@ class EnhancedPlayerManager private constructor() { private var videoEqualizer: EqualizerAudioProcessor? = null private var eqObserver: Job? = null private var audioEffects: AudioEffectsEntryPoint? = null - private var announcedAudioSession = 0 + private var audioSessionAnnouncer: AudioSessionAnnouncer? = null // State management private val _playerState = MutableStateFlow(EnhancedPlayerState()) @@ -752,7 +753,12 @@ class EnhancedPlayerManager private constructor() { player?.addAnalyticsListener( RenderedFirstFrameListener(currentVideoId = { currentVideoId }) { videoId -> _firstFrameVideoId.value = videoId }, ) - player?.let { announceAudioSession(effects, it.audioSessionId) } + player?.let { built -> + audioSessionAnnouncer = + AudioSessionAnnouncer(effects.audioSessionRegistry(), AudioEffect.CONTENT_TYPE_MOVIE) { + audioFeaturesManager?.onAudioSessionChanged() + }.also { it.attach(built) } + } audioFeaturesManager?.setPlayer(player!!) @@ -3327,16 +3333,6 @@ class EnhancedPlayerManager private constructor() { duration - player.currentPosition <= LIVE_EDGE_THRESHOLD_MS } - private fun announceAudioSession( - effects: AudioEffectsEntryPoint, - sessionId: Int, - ) { - val registry = effects.audioSessionRegistry() - if (announcedAudioSession != sessionId) registry.close(announcedAudioSession) - announcedAudioSession = sessionId - registry.open(sessionId, AudioEffect.CONTENT_TYPE_MOVIE) - } - fun release() { Log.d(TAG, "release() called") releaseAdvanceWakeLock() @@ -3351,8 +3347,8 @@ class EnhancedPlayerManager private constructor() { audioFeaturesManager?.clearPlayer() buildScope.close() surfaceManager?.release(player) - audioEffects?.audioSessionRegistry()?.close(announcedAudioSession) - announcedAudioSession = 0 + audioSessionAnnouncer?.detach() + audioSessionAnnouncer = null player?.release() player = null trackSelector = null diff --git a/app/src/main/java/io/github/aedev/flow/player/audio/AudioFeaturesManager.kt b/app/src/main/java/io/github/aedev/flow/player/audio/AudioFeaturesManager.kt index 5566141337..75367cc1a1 100644 --- a/app/src/main/java/io/github/aedev/flow/player/audio/AudioFeaturesManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/audio/AudioFeaturesManager.kt @@ -84,6 +84,14 @@ class AudioFeaturesManager( } } + /** Moves the gain onto the player's audio session, which ExoPlayer reports only after build. */ + fun onAudioSessionChanged() { + val player = playerRef ?: return + loudnessEnhancer?.release() + loudnessEnhancer = null + applyLoudnessGain(player) + } + /** * Clear the player reference (call on release). */ diff --git a/app/src/main/java/io/github/aedev/flow/player/audio/AudioSessionAnnouncer.kt b/app/src/main/java/io/github/aedev/flow/player/audio/AudioSessionAnnouncer.kt new file mode 100644 index 0000000000..44ce8c0806 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/player/audio/AudioSessionAnnouncer.kt @@ -0,0 +1,48 @@ +package io.github.aedev.flow.player.audio + +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer + +/** + * Keeps [AudioSessionRegistry] in step with one player's audio session. Since Media3 1.6 the id is + * generated after the player is built and arrives through [onAudioSessionIdChanged], so reading + * `audioSessionId` right after `build()` returns 0. + * + * Attach it in the same main-thread task that built the player, so the first id is not missed, and + * detach it before the player is released. + */ +class AudioSessionAnnouncer( + private val registry: AudioSessionRegistry, + private val contentType: Int, + private val onSessionId: (Int) -> Unit = {}, +) : Player.Listener { + private var player: Player? = null + private var sessionId = 0 + + fun attach(player: ExoPlayer) { + this.player = player + player.addListener(this) + onAudioSessionIdChanged(player.audioSessionId) + } + + fun detach() { + player?.removeListener(this) + player = null + registry.close(sessionId) + sessionId = 0 + } + + override fun onAudioSessionIdChanged(audioSessionId: Int) { + val id = audioSessionId.coerceAtLeast(0) + if (id == sessionId) return + registry.close(sessionId) + sessionId = id + registry.open(id, contentType) + if (player?.isPlaying == true) registry.playing(id) + onSessionId(id) + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) registry.playing(sessionId) else registry.paused(sessionId) + } +} diff --git a/app/src/main/java/io/github/aedev/flow/player/audio/AudioSessionRegistry.kt b/app/src/main/java/io/github/aedev/flow/player/audio/AudioSessionRegistry.kt index 281a6a5c9e..aff57055a5 100644 --- a/app/src/main/java/io/github/aedev/flow/player/audio/AudioSessionRegistry.kt +++ b/app/src/main/java/io/github/aedev/flow/player/audio/AudioSessionRegistry.kt @@ -18,6 +18,10 @@ import javax.inject.Singleton /** * Announces each player's audio session to equalizer apps, which attach their effects only to * sessions they have been told about; Media3 sends no such broadcast itself. + * + * Several players can be alive at once, and MusicFX follows only the session a package announced + * last, so a session is announced when it starts playing, or when it appears while nothing else + * plays. The active session is the one announced last. */ @Singleton class AudioSessionRegistry @@ -26,28 +30,54 @@ class AudioSessionRegistry @ApplicationContext private val context: Context, ) { private val open = LinkedHashMap() + private val playing = LinkedHashSet() private val _activeSessionId = MutableStateFlow(AudioEffect.ERROR_BAD_VALUE) - /** The session opened most recently and still open, or [AudioEffect.ERROR_BAD_VALUE]. */ + /** The session announced most recently and still open, or [AudioEffect.ERROR_BAD_VALUE]. */ val activeSessionId: StateFlow = _activeSessionId.asStateFlow() + /** The content type [sessionId] was opened with, or null when it is not open. */ + @Synchronized + fun contentTypeOf(sessionId: Int): Int? = open[sessionId] + @Synchronized fun open( sessionId: Int, contentType: Int, ) { if (sessionId <= 0 || open[sessionId] == contentType) return - open.remove(sessionId) open[sessionId] = contentType - _activeSessionId.value = sessionId - broadcast(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION, sessionId, contentType) + val active = _activeSessionId.value + if (active == sessionId || active <= 0 || active !in playing) announce(sessionId) + } + + @Synchronized + fun playing(sessionId: Int) { + if (sessionId !in open) return + playing.remove(sessionId) + playing.add(sessionId) + if (_activeSessionId.value != sessionId) announce(sessionId) + } + + @Synchronized + fun paused(sessionId: Int) { + playing.remove(sessionId) } @Synchronized fun close(sessionId: Int) { + playing.remove(sessionId) val contentType = open.remove(sessionId) ?: return - _activeSessionId.value = open.keys.lastOrNull() ?: AudioEffect.ERROR_BAD_VALUE broadcast(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION, sessionId, contentType) + if (_activeSessionId.value != sessionId) return + val next = playing.lastOrNull() ?: open.keys.lastOrNull() + if (next != null) announce(next) else _activeSessionId.value = AudioEffect.ERROR_BAD_VALUE + } + + private fun announce(sessionId: Int) { + val contentType = open[sessionId] ?: return + _activeSessionId.value = sessionId + broadcast(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION, sessionId, contentType) } private fun broadcast( diff --git a/app/src/main/java/io/github/aedev/flow/player/shorts/ShortsPlayerPool.kt b/app/src/main/java/io/github/aedev/flow/player/shorts/ShortsPlayerPool.kt index ebd3dbb091..59e79f031f 100644 --- a/app/src/main/java/io/github/aedev/flow/player/shorts/ShortsPlayerPool.kt +++ b/app/src/main/java/io/github/aedev/flow/player/shorts/ShortsPlayerPool.kt @@ -30,6 +30,7 @@ import io.github.aedev.flow.data.local.PlayerPreferences import io.github.aedev.flow.data.model.ShortVideo import io.github.aedev.flow.player.analytics.PlaybackAnalyticsLogger import io.github.aedev.flow.player.audio.AudioEffectsEntryPoint +import io.github.aedev.flow.player.audio.AudioSessionAnnouncer import io.github.aedev.flow.player.audio.eq.EqualizerAudioProcessor import io.github.aedev.flow.player.cache.PlayerCacheManager import io.github.aedev.flow.player.cache.SharedPlayerCacheProvider @@ -97,7 +98,7 @@ class ShortsPlayerPool private constructor() { private val playerAudioManifests = arrayOfNulls(POOL_SIZE) private val equalizers = arrayOfNulls(POOL_SIZE) - private var audioEffects: AudioEffectsEntryPoint? = null + private val audioSessionAnnouncers = arrayOfNulls(POOL_SIZE) private var equalizerScope: CoroutineScope? = null private var isInitialized = false @@ -204,7 +205,7 @@ class ShortsPlayerPool private constructor() { }, ) - val effects = EntryPointAccessors.fromApplication(appContext, AudioEffectsEntryPoint::class.java).also { audioEffects = it } + val effects = EntryPointAccessors.fromApplication(appContext, AudioEffectsEntryPoint::class.java) val equalizerSpec = effects.equalizerRepository().processingSpec equalizerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default).also { scope -> @@ -217,7 +218,9 @@ class ShortsPlayerPool private constructor() { equalizers[i] = equalizer players[i] = createShortsPlayer(appContext, equalizer).also { - effects.audioSessionRegistry().open(it.audioSessionId, AudioEffect.CONTENT_TYPE_MOVIE) + audioSessionAnnouncers[i] = + AudioSessionAnnouncer(effects.audioSessionRegistry(), AudioEffect.CONTENT_TYPE_MOVIE) + .apply { attach(it) } } playerOwnerIndices[i] = null playerVideoIds[i] = null @@ -738,7 +741,8 @@ class ShortsPlayerPool private constructor() { equalizerScope?.cancel() equalizerScope = null for (i in 0 until POOL_SIZE) { - players[i]?.let { audioEffects?.audioSessionRegistry()?.close(it.audioSessionId) } + audioSessionAnnouncers[i]?.detach() + audioSessionAnnouncers[i] = null equalizers[i] = null players[i]?.stop() players[i]?.release() 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 44f599a505..065161658a 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 @@ -67,6 +67,7 @@ import io.github.aedev.flow.player.MusicPlaybackRecoveryPlanner import io.github.aedev.flow.player.MusicQueuePlanner import io.github.aedev.flow.player.MusicRadioPlanner import io.github.aedev.flow.player.PlaylistQueueOrder +import io.github.aedev.flow.player.audio.AudioSessionAnnouncer import io.github.aedev.flow.player.audio.AudioSessionRegistry import io.github.aedev.flow.player.audio.eq.EqualizerAudioProcessor import io.github.aedev.flow.player.audio.shouldHandleAudioFocus @@ -236,6 +237,7 @@ class Media3MusicService : MediaLibraryService() { @Inject lateinit var audioSessions: AudioSessionRegistry + private var audioSessionAnnouncer: AudioSessionAnnouncer? = null @Inject lateinit var autoLibrary: AutoLibrary @@ -452,9 +454,11 @@ class Media3MusicService : MediaLibraryService() { .build() // Expose audio session ID for external audio processors (James DSP, etc.) - currentAudioSessionId = player.audioSessionId - Log.i(TAG, "Audio session initialized - Session ID: $currentAudioSessionId") - audioSessions.open(player.audioSessionId, AudioEffect.CONTENT_TYPE_MUSIC) + audioSessionAnnouncer = + AudioSessionAnnouncer(audioSessions, AudioEffect.CONTENT_TYPE_MUSIC) { sessionId -> + currentAudioSessionId = sessionId + Log.i(TAG, "Audio session initialized - Session ID: $sessionId") + }.also { it.attach(player) } updateOffload { it.copy(processing = equalizerRepository.needsProcessing.value) } observeOffload() @@ -1320,7 +1324,8 @@ class Media3MusicService : MediaLibraryService() { finalizeListenSession() // Clear audio session ID so external processors know we're gone - audioSessions.close(currentAudioSessionId) + audioSessionAnnouncer?.detach() + audioSessionAnnouncer = null currentAudioSessionId = 0 Log.i(TAG, "Audio session destroyed") diff --git a/app/src/test/java/io/github/aedev/flow/player/audio/AudioSessionAnnouncerTest.kt b/app/src/test/java/io/github/aedev/flow/player/audio/AudioSessionAnnouncerTest.kt new file mode 100644 index 0000000000..7cd233a4c4 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/player/audio/AudioSessionAnnouncerTest.kt @@ -0,0 +1,68 @@ +package io.github.aedev.flow.player.audio + +import android.media.audiofx.AudioEffect +import androidx.media3.exoplayer.ExoPlayer +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +class AudioSessionAnnouncerTest { + private val registry = AudioSessionRegistry(mockk(relaxed = true)) + + private fun player(sessionId: Int): ExoPlayer = + mockk(relaxed = true) { + every { audioSessionId } returns sessionId + every { isPlaying } returns false + } + + @Test + fun `the id ExoPlayer reports after build is opened`() { + val ids = mutableListOf() + val announcer = AudioSessionAnnouncer(registry, AudioEffect.CONTENT_TYPE_MOVIE) { ids += it } + announcer.attach(player(0)) + + assertThat(registry.activeSessionId.value).isEqualTo(AudioEffect.ERROR_BAD_VALUE) + + announcer.onAudioSessionIdChanged(41) + + assertThat(registry.activeSessionId.value).isEqualTo(41) + assertThat(registry.contentTypeOf(41)).isEqualTo(AudioEffect.CONTENT_TYPE_MOVIE) + assertThat(ids).containsExactly(41) + } + + @Test + fun `a changed id closes the old session`() { + val announcer = AudioSessionAnnouncer(registry, AudioEffect.CONTENT_TYPE_MUSIC) + announcer.attach(player(41)) + announcer.onAudioSessionIdChanged(42) + + assertThat(registry.contentTypeOf(41)).isNull() + assertThat(registry.activeSessionId.value).isEqualTo(42) + } + + @Test + fun `the player that starts playing takes the system equalizer`() { + val video = AudioSessionAnnouncer(registry, AudioEffect.CONTENT_TYPE_MOVIE).apply { attach(player(41)) } + AudioSessionAnnouncer(registry, AudioEffect.CONTENT_TYPE_MUSIC).apply { attach(player(42)) } + + assertThat(registry.activeSessionId.value).isEqualTo(42) + + video.onIsPlayingChanged(true) + + assertThat(registry.activeSessionId.value).isEqualTo(41) + } + + @Test + fun `detach removes the listener and closes the session`() { + val exo = player(41) + val announcer = AudioSessionAnnouncer(registry, AudioEffect.CONTENT_TYPE_MOVIE) + announcer.attach(exo) + announcer.detach() + + verify { exo.removeListener(announcer) } + assertThat(registry.contentTypeOf(41)).isNull() + assertThat(registry.activeSessionId.value).isEqualTo(AudioEffect.ERROR_BAD_VALUE) + } +} diff --git a/app/src/test/java/io/github/aedev/flow/player/audio/AudioSessionRegistryTest.kt b/app/src/test/java/io/github/aedev/flow/player/audio/AudioSessionRegistryTest.kt new file mode 100644 index 0000000000..2168ca6c9a --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/player/audio/AudioSessionRegistryTest.kt @@ -0,0 +1,77 @@ +package io.github.aedev.flow.player.audio + +import android.media.audiofx.AudioEffect +import com.google.common.truth.Truth.assertThat +import io.mockk.mockk +import org.junit.Test + +class AudioSessionRegistryTest { + private val registry = AudioSessionRegistry(mockk(relaxed = true)) + private val music = AudioEffect.CONTENT_TYPE_MUSIC + private val movie = AudioEffect.CONTENT_TYPE_MOVIE + + @Test + fun `session id 0 is never opened`() { + registry.open(0, music) + registry.playing(0) + + assertThat(registry.activeSessionId.value).isEqualTo(AudioEffect.ERROR_BAD_VALUE) + assertThat(registry.contentTypeOf(0)).isNull() + } + + @Test + fun `a session that appears while nothing plays becomes active`() { + registry.open(11, music) + + assertThat(registry.activeSessionId.value).isEqualTo(11) + } + + @Test + fun `a new idle session does not take over from one that is playing`() { + registry.open(11, movie) + registry.playing(11) + registry.open(22, music) + + assertThat(registry.activeSessionId.value).isEqualTo(11) + } + + @Test + fun `the session that starts playing becomes active`() { + registry.open(11, movie) + registry.open(22, music) + registry.playing(11) + registry.paused(11) + registry.playing(22) + + assertThat(registry.activeSessionId.value).isEqualTo(22) + assertThat(registry.contentTypeOf(22)).isEqualTo(music) + } + + @Test + fun `closing the active session falls back to one still playing`() { + registry.open(11, movie) + registry.open(22, movie) + registry.open(33, music) + registry.playing(22) + registry.playing(33) + registry.close(33) + + assertThat(registry.activeSessionId.value).isEqualTo(22) + } + + @Test + fun `closing the last session clears the active one`() { + registry.open(11, music) + registry.close(11) + + assertThat(registry.activeSessionId.value).isEqualTo(AudioEffect.ERROR_BAD_VALUE) + } + + @Test + fun `playing a session that is not open changes nothing`() { + registry.open(11, music) + registry.playing(99) + + assertThat(registry.activeSessionId.value).isEqualTo(11) + } +} From 3932ed844337819d526d44f010752b3b8278ed37 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:46:35 +0300 Subject: [PATCH 06/14] fix(audio): open the system equalizer on the playing session --- .../flow/ui/screens/equalizer/EqualizerScreen.kt | 9 +++++++-- .../flow/ui/screens/equalizer/EqualizerViewModel.kt | 12 +++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/equalizer/EqualizerScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/equalizer/EqualizerScreen.kt index 845325dc3c..8702bbe39d 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/equalizer/EqualizerScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/equalizer/EqualizerScreen.kt @@ -85,7 +85,7 @@ fun EqualizerScreen( var showPresets by rememberSaveable { mutableStateOf(false) } var selectedBand by rememberSaveable { mutableStateOf(null) } var dialog by remember { mutableStateOf(null) } - val systemEqualizer = remember { viewModel.systemEqualizerIntent() } + val hasSystemEqualizer = remember { viewModel.systemEqualizerIntent() != null } val undoLabel = stringResource(R.string.undo) val panes = rememberFlowPaneState() val twoPane = !inSettings && panes.showsSidePane @@ -184,7 +184,12 @@ fun EqualizerScreen( twoPane = twoPane, onOpenPresets = { showPresets = true }, onSaveAs = { dialog = EqDialog.SaveAs }, - onSystemEqualizer = systemEqualizer?.let { intent -> { openSystemEqualizer.launch(intent) } }, + onSystemEqualizer = + if (hasSystemEqualizer) { + { viewModel.systemEqualizerIntent()?.let(openSystemEqualizer::launch) } + } else { + null + }, modifier = Modifier.padding(padding), ) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/equalizer/EqualizerViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/equalizer/EqualizerViewModel.kt index f729990000..2b27b48e8b 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/equalizer/EqualizerViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/equalizer/EqualizerViewModel.kt @@ -278,13 +278,19 @@ class EqualizerViewModel _importPreview.value = null } - /** An installed equalizer panel for the current session, or null when none is installed. */ + /** + * An installed equalizer panel for the session playing now, or null when none is installed. + * Built at tap time: the active session changes as players start and stop. + */ fun systemEqualizerIntent(): Intent? { + val session = sessions.activeSessionId.value val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL) .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) - .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) - val session = sessions.activeSessionId.value + .putExtra( + AudioEffect.EXTRA_CONTENT_TYPE, + sessions.contentTypeOf(session) ?: AudioEffect.CONTENT_TYPE_MUSIC, + ) if (session > 0) intent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, session) return intent.takeIf { it.resolveActivity(context.packageManager) != null } } From f5eac0a7c7f6e313923817eff1cf0c31b6e91fb4 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:02:13 +0300 Subject: [PATCH 07/14] fix(playlists): read Shorts-only playlists --- .../repository/YouTubePlaylistRepository.kt | 48 ++++++++------ .../io/github/aedev/flow/innertube/YouTube.kt | 13 +++- .../flow/innertube/pages/VideoPlaylistPage.kt | 60 ++++++++++++++---- .../innertube/pages/VideoPlaylistPageTest.kt | 62 +++++++++++++++++++ 4 files changed, 149 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/data/repository/YouTubePlaylistRepository.kt b/app/src/main/java/io/github/aedev/flow/data/repository/YouTubePlaylistRepository.kt index 4de6097aba..10d0424eaf 100644 --- a/app/src/main/java/io/github/aedev/flow/data/repository/YouTubePlaylistRepository.kt +++ b/app/src/main/java/io/github/aedev/flow/data/repository/YouTubePlaylistRepository.kt @@ -56,25 +56,35 @@ class YouTubePlaylistRepository completeLists[playlistId] = CachedList(page.copy(continuation = null), System.currentTimeMillis()) } - suspend fun firstPage(playlistId: String): RemotePlaylistPage? = - fetch { YouTube.videoPlaylistPage(playlistId) } - ?.takeIf { it.title != null || it.videos.isNotEmpty() } - ?.let { page -> - RemotePlaylistPage( - title = page.title.orEmpty(), - ownerName = page.ownerName, - ownerId = page.ownerId?.takeIf(String::isNotBlank), - description = page.description.orEmpty(), - thumbnailUrl = - page.thumbnailUrl ?: page.videos - .firstOrNull() - ?.thumbnailUrl - .orEmpty(), - videos = page.videos, - continuation = page.continuation, - isCourse = page.isCourse, - ) - } + suspend fun firstPage(playlistId: String): RemotePlaylistPage? { + val page = + fetch { YouTube.videoPlaylistPage(playlistId) } + ?.takeIf { it.title != null || it.videos.isNotEmpty() } ?: return null + // A Shorts grid stops at 100 with no next page; its "show unavailable" view is a list + // that pages through every video. Only grids ask for it: on a list it would add + // unplayable entries that look playable. + val list = + if (page.isShortsGrid) { + fetch { YouTube.videoPlaylistPage(playlistId, params = YouTube.PLAYLIST_SHOW_UNAVAILABLE_PARAMS) } + ?.takeIf { it.videos.isNotEmpty() } + } else { + null + } ?: page + return RemotePlaylistPage( + title = page.title.orEmpty(), + ownerName = page.ownerName, + ownerId = page.ownerId?.takeIf(String::isNotBlank), + description = page.description.orEmpty(), + thumbnailUrl = + page.thumbnailUrl ?: list.videos + .firstOrNull() + ?.thumbnailUrl + .orEmpty(), + videos = list.videos, + continuation = list.continuation, + isCourse = page.isCourse, + ) + } /** The videos behind [continuation] and the token after them. */ suspend fun nextPage( diff --git a/app/src/main/java/io/github/aedev/flow/innertube/YouTube.kt b/app/src/main/java/io/github/aedev/flow/innertube/YouTube.kt index cf295fabc1..afd723208c 100644 --- a/app/src/main/java/io/github/aedev/flow/innertube/YouTube.kt +++ b/app/src/main/java/io/github/aedev/flow/innertube/YouTube.kt @@ -150,6 +150,9 @@ object YouTube { private const val CHANNEL_POSTS_PARAMS = "EgVwb3N0c_IGBAoCSgA=" private const val CHANNEL_SHORTS_PARAMS = "EgZzaG9ydHPyBgUKA5oBAA==" + /** The playlist header menu's "Show unavailable videos" view, which lists a Shorts grid in full. */ + const val PLAYLIST_SHOW_UNAVAILABLE_PARAMS = "wgYCCAA=" + var locale: YouTubeLocale get() = innerTube.locale set(value) { @@ -841,17 +844,21 @@ object YouTube { communityPostCommentsPage(continuation = continuation) } - /** A YouTube playlist's first page when [continuation] is null, otherwise the page it points to. */ + /** + * A YouTube playlist's first page when [continuation] is null, otherwise the page it points to. + * [params] picks a view of the first page, such as [PLAYLIST_SHOW_UNAVAILABLE_PARAMS]. + */ suspend fun videoPlaylistPage( playlistId: String, continuation: String? = null, + params: String? = null, ): Result = runCatching { val response = innerTube.channelBrowse( - client = currentWebClient(), + client = WEB, channelId = "VL$playlistId".takeIf { continuation == null }, - params = null, + params = params.takeIf { continuation == null }, continuation = continuation, ) Json.parseToJsonElement(response.bodyAsText()).toVideoPlaylistPage() diff --git a/app/src/main/java/io/github/aedev/flow/innertube/pages/VideoPlaylistPage.kt b/app/src/main/java/io/github/aedev/flow/innertube/pages/VideoPlaylistPage.kt index ff2d2269d6..db1b64b139 100644 --- a/app/src/main/java/io/github/aedev/flow/innertube/pages/VideoPlaylistPage.kt +++ b/app/src/main/java/io/github/aedev/flow/innertube/pages/VideoPlaylistPage.kt @@ -20,6 +20,10 @@ data class VideoPlaylistPage( val videos: List