Skip to content
Merged
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,12 @@
## New
- Playlists say when YouTube hides unavailable videos (#1358)
- The "not a bot" error can reset the YouTube session in one tap (#1354)

## Fixed
- Flow no longer crashes on first launch while it creates its settings (#1355) (#1362)
- The crash screen shows a report once instead of on every launch (#1355)
- Status bar icons stay readable over the open video player in light theme (#1375)
- The system equalizer now changes how Flow sounds (#1373)
- Playlists made only of Shorts load again (#1358)
- A playlist that stops loading partway no longer shortens your saved copy (#1358)
- When every YouTube client asks to confirm you're not a bot, Flow retries once with a fresh session (#1354)
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
14 changes: 8 additions & 6 deletions app/src/main/java/io/github/aedev/flow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -231,20 +231,22 @@ 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) {
SideEffect { splashController.contentReady = true }
FlowTheme(theme.copy(fontFamily = FlowFontFamily)) {
CrashReportScreen(
report = pendingCrashLog!!,
onContinue = {
FlowCrashHandler.clearLastCrash(applicationContext)
pendingCrashLog = null
},
onContinue = { pendingCrashLog = null },
)
}
return@setContent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Context, DataStore<Preferences>> =
preferencesDataStore(
name = name,
corruptionHandler = ReplaceFileCorruptionHandler { corruption ->
Log.w(SAFE_DATA_STORE_TAG, "Resetting corrupted preferences DataStore: $name", corruption)
emptyPreferences()
}
)
fun safePreferencesDataStore(name: String): ReadOnlyProperty<Context, DataStore<Preferences>> {
val delegate =
preferencesDataStore(
name = name,
corruptionHandler =
ReplaceFileCorruptionHandler { corruption ->
Log.w(SAFE_DATA_STORE_TAG, "Resetting corrupted preferences DataStore: $name", corruption)
emptyPreferences()
},
)
var wrapped: DataStore<Preferences>? = null
return object : ReadOnlyProperty<Context, DataStore<Preferences>> {
override fun getValue(
thisRef: Context,
property: KProperty<*>,
): DataStore<Preferences> =
wrapped ?: synchronized(this) {
wrapped ?: ReadSafeDataStore(name, delegate.getValue(thisRef, property)).also { wrapped = it }
}
}
}

internal class ReadSafeDataStore(
private val name: String,
private val delegate: DataStore<Preferences>,
private val retryDelayMs: Long = READ_RETRY_DELAY_MS,
) : DataStore<Preferences> {
// 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<Preferences> =
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package io.github.aedev.flow.data.repository

import io.github.aedev.flow.data.model.Video

/** The videos a walk collected, and whether it reached the playlist's last page. */
internal data class PlaylistWalk(
val videos: List<Video>,
val finished: Boolean,
)

/**
* Reads the pages after a playlist's first while YouTube sends a token it has not sent before.
* A page can parse to nothing and still point further, so only the token ends the walk. A failed
* page, a repeated token, the [maxPages] budget or [keepGoing] turning false leave it unfinished,
* and an unfinished list must never be cached or synced as the whole playlist.
*/
internal suspend fun walkPlaylistPages(
firstVideos: List<Video>,
firstToken: String?,
maxPages: Int,
nextPage: suspend (continuation: String) -> Pair<List<Video>, String?>?,
keepGoing: (videos: List<Video>) -> Boolean = { true },
onPage: (videos: List<Video>) -> Unit = {},
): PlaylistWalk {
var videos = firstVideos.distinctBy { it.id }
var token = firstToken
val seen = HashSet<String>()
var pages = 1
while (token != null) {
if (!keepGoing(videos) || pages >= maxPages || !seen.add(token)) return PlaylistWalk(videos, finished = false)
val (more, next) = nextPage(token) ?: return PlaylistWalk(videos, finished = false)
videos = (videos + more).distinctBy { it.id }
onPage(videos)
token = next
pages++
}
return PlaylistWalk(videos, finished = true)
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ data class RemotePlaylistPage(
val videos: List<Video>,
val continuation: String?,
val isCourse: Boolean = false,
)
/** The header's count, unavailable videos included. */
val videoCount: Int? = null,
) {
/** The whole list is shorter than the header's count: YouTube leaves out unavailable videos. */
fun hidesUnavailable(loaded: Int = videos.size): Boolean = videoCount != null && videoCount > loaded
}

/**
* YouTube playlists from InnerTube, one request per page. The first page shows at once and later
Expand Down Expand Up @@ -56,46 +61,53 @@ 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,
videoCount = page.videoCount ?: list.videoCount,
)
}

/** The videos behind [continuation] and the token after them. */
suspend fun nextPage(
playlistId: String,
continuation: String,
): Pair<List<Video>, String?>? = fetch { YouTube.videoPlaylistPage(playlistId, continuation) }?.let { it.videos to it.continuation }

/** Every video, page by page, for a caller that needs the whole list (syncing a saved copy). */
/**
* Every video, page by page, for a caller that needs the whole list (syncing a saved copy).
* Null unless every page arrived: a partial list would cut the saved copy short.
*/
suspend fun complete(playlistId: String): RemotePlaylistPage? {
cachedComplete(playlistId)?.let { return it }
val first = firstPage(playlistId) ?: return null
val videos = first.videos.toMutableList()
var token = first.continuation
var pages = 1
while (token != null && pages < MAX_PAGES) {
val (more, next) = nextPage(playlistId, token) ?: break
videos += more
token = next.takeIf { more.isNotEmpty() }
pages++
}
val complete = first.copy(videos = videos.distinctBy { it.id }, continuation = null)
val walk = walkPlaylistPages(first.videos, first.continuation, MAX_PAGES, { nextPage(playlistId, it) })
if (!walk.finished) return null
val complete = first.copy(videos = walk.videos, continuation = null)
rememberComplete(playlistId, complete)
return complete
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")

/**
Expand Down
13 changes: 10 additions & 3 deletions app/src/main/java/io/github/aedev/flow/innertube/YouTube.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<VideoPlaylistPage> =
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()
Expand Down
Loading
Loading