diff --git a/.github/nightly-notes/2026-10-25-fix-dub-downloads-geo-subs-music-crash-buffer.md b/.github/nightly-notes/2026-10-25-fix-dub-downloads-geo-subs-music-crash-buffer.md new file mode 100644 index 0000000000..d0eb0cc4d8 --- /dev/null +++ b/.github/nightly-notes/2026-10-25-fix-dub-downloads-geo-subs-music-crash-buffer.md @@ -0,0 +1,8 @@ +## New +- Choose which audio track, original or any dub, a video download is saved with (#1444) + +## Fixed +- Downloads keep the original audio instead of a dubbed track (#1444) +- Hide unplayable videos now hides removed and region-blocked uploads in Subscriptions (#1443) +- Library no longer crashes when the music service is turned off on the device (#1439) +- Long custom buffers now take effect, as far as the phone's memory allows (#1438) 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 1e29ecc63b..189defd655 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 @@ -51,7 +51,7 @@ const val DEFAULT_BACKGROUND_PLAY_ENABLED = true val DEFAULT_NAV_TAB_ORDER = listOf(0, 1, 2, 3, 4, 5, 6) -private const val MAX_UNPLAYABLE_VIDEO_IDS = 300 +private const val MAX_UNPLAYABLE_VIDEO_IDS = 1_000 /** * Preferences that never leave the device in a backup: the proxy password, and SponsorBlock's @@ -3885,7 +3885,7 @@ enum class BufferProfile( AGGRESSIVE("Fast Start", 5_000, 30_000, 500, 2_500), // Balanced: Good default for most connections - STABLE("Balanced", 30_000, 50_000, 2_500, 5_000), + STABLE("Balanced", 20_000, 50_000, 2_500, 5_000), // Data Saver: Minimize data usage with smaller buffers DATASAVER("Data Saver", 12_000, 25_000, 1_500, 3_000), diff --git a/app/src/main/java/io/github/aedev/flow/data/video/DownloadStreamPolicy.kt b/app/src/main/java/io/github/aedev/flow/data/video/DownloadStreamPolicy.kt index 4bbda98d9e..ccc41e0f77 100644 --- a/app/src/main/java/io/github/aedev/flow/data/video/DownloadStreamPolicy.kt +++ b/app/src/main/java/io/github/aedev/flow/data/video/DownloadStreamPolicy.kt @@ -93,7 +93,9 @@ object DownloadStreamPolicy { /** * The audio formats a download can use: AAC only, since every download is an MP4 or M4A and * Media3 cuts the start of Opus in MP4. DRC twins and URL-less formats are dropped, one entry - * per (bitrate, track, language, role), highest bitrate first. + * per (bitrate, track, language, role). The original track comes first, then the dubs by + * language, each highest bitrate first: dubs often share one bitrate, so a bitrate-first list + * opened on whichever dub sorted first (#1444). */ fun buildDownloadAudioFormats(formats: List): List = formats @@ -107,8 +109,10 @@ object DownloadStreamPolicy { format.isOriginal.toString(), ).joinToString("|") }.sortedWith( - compareByDescending { audioBitrateKbps(it) } - .thenBy { audioLocale(it)?.displayLanguage.orEmpty() }, + compareByDescending { it.isOriginal } + .thenBy { audioLocale(it)?.displayLanguage.orEmpty() } + .thenBy { it.audioTrack?.id.orEmpty() } + .thenByDescending { audioBitrateKbps(it) }, ) /** @@ -142,6 +146,37 @@ object DownloadStreamPolicy { } } + /** + * The tracks a video download can pair with: one entry per track, its best AAC, in + * [buildDownloadAudioFormats] order so the original comes first. A video with one track gives one + * entry, and the dialog then shows no choice. + */ + fun audioTrackChoices(streams: List): List = + streams + .filter(::isAacFormat) + .groupBy { listOf(it.audioTrack?.id.orEmpty(), audioLocale(it)?.toLanguageTag().orEmpty(), it.isOriginal.toString()) } + .values + .map { track -> track.maxBy(::audioBitrate) } + + /** + * The row an audio-only download opens on: [pickAacAudio]'s track, so the original unless a + * language is set. The remembered row wins only when it is that same track, or the last dub + * someone downloaded would become the default for every later video. + */ + fun defaultAudioIndex( + streams: List, + preferredLang: String?, + rememberedLabel: String?, + labelOf: (Format) -> String, + ): Int { + val pick = pickAacAudio(streams, preferredLang) ?: return 0 + val remembered = + streams.indexOfFirst { + labelOf(it) == rememberedLabel && it.audioTrack?.id == pick.audioTrack?.id && it.isOriginal == pick.isOriginal + } + return remembered.takeIf { it >= 0 } ?: streams.indexOf(pick).coerceAtLeast(0) + } + fun isAacFormat(format: Format): Boolean = containerOf(format.mimeType) == AUDIO_MP4 private fun containerOf(mimeType: String): String = mimeType.substringBefore(';').trim().lowercase() diff --git a/app/src/main/java/io/github/aedev/flow/data/video/downloader/resolve/DownloadStreamResolver.kt b/app/src/main/java/io/github/aedev/flow/data/video/downloader/resolve/DownloadStreamResolver.kt index d4fe77cc73..c1d45231d6 100644 --- a/app/src/main/java/io/github/aedev/flow/data/video/downloader/resolve/DownloadStreamResolver.kt +++ b/app/src/main/java/io/github/aedev/flow/data/video/downloader/resolve/DownloadStreamResolver.kt @@ -104,13 +104,18 @@ class DownloadStreamResolver // A song is only its audio, so it is the one download the song quality setting shapes. val quality = if (request.wantsAudioOnly) defaults.musicQuality else MusicAudioQuality.HIGH + // Every dub of a video shares one itag (140 for AAC), so a picked track is found by its + // track id first, or the itag alone lands on whichever dub sorts first (#1444). + val trackFormats = + request.audioTrackId + ?.let { id -> audioFormats.filter { it.audioTrack?.id == id } } + .orEmpty() + val itagPool = if (request.audioTrackId != null) trackFormats else audioFormats val audio = request.audioItag - ?.let { itag -> audioFormats.firstOrNull { it.itag == itag } } + ?.let { itag -> itagPool.firstOrNull { it.itag == itag } } ?.takeIf { DownloadStreamPolicy.isAacFormat(it) } - ?: request.audioTrackId?.let { id -> - DownloadStreamPolicy.pickAacAudio(audioFormats.filter { it.audioTrack?.id == id }, language, quality) - } + ?: DownloadStreamPolicy.pickAacAudio(trackFormats, language, quality) ?: DownloadStreamPolicy.pickAacAudio(audioFormats, language, quality) ?: return if (result.audioFormats.any { !it.url.isNullOrBlank() }) { ResolveOutcome.NoCompatibleAudio 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 198f638276..7517e8dc3d 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 @@ -13,10 +13,12 @@ import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.MoreExecutors +import io.github.aedev.flow.R import io.github.aedev.flow.data.local.AudioSettingsPersistence import io.github.aedev.flow.data.local.QueuePersistence import io.github.aedev.flow.data.localmedia.LocalMediaIds import io.github.aedev.flow.data.music.model.MusicTrack +import io.github.aedev.flow.player.error.PlayerDiagnostics import io.github.aedev.flow.player.musicvideo.MusicVideoItems import io.github.aedev.flow.player.musicvideo.MusicVideoPlanner import io.github.aedev.flow.service.Media3MusicService @@ -258,7 +260,23 @@ object EnhancedMusicPlayerManager { queuePersistence = QueuePersistence.getInstance(context) audioSettingsPersistence = AudioSettingsPersistence.getInstance(context) - val sessionToken = SessionToken(context, ComponentName(context, Media3MusicService::class.java)) + connectController(context) + startPositionUpdates() + } + + /** + * Builds the controller for the music service. False when Android reports the service missing, + * which happens when the component is disabled on the device (a blocker app, some ROMs): Media3 + * then throws from the token, and that has to cost music playback, not the app (#1439). + */ + private fun connectController(context: Context): Boolean { + val sessionToken = + try { + SessionToken(context, ComponentName(context, Media3MusicService::class.java)) + } catch (e: IllegalArgumentException) { + PlayerDiagnostics.logError("EnhancedMusicPlayer", "music service unavailable: ${e.message}", e) + return false + } controllerFuture = MediaController.Builder(context, sessionToken).buildAsync() controllerFuture?.addListener({ @@ -305,8 +323,7 @@ object EnhancedMusicPlayerManager { e.printStackTrace() } }, MoreExecutors.directExecutor()) - - startPositionUpdates() + return true } private fun setupPlayerListener(controller: Player) { @@ -639,6 +656,11 @@ object EnhancedMusicPlayerManager { sourceName: String? = null, localUriOverrides: Map = emptyMap(), ) { + val context = appContext + if (player == null && controllerFuture == null && context != null && !connectController(context)) { + showPlaybackWarning(context.getString(R.string.music_service_disabled)) + return + } queueStarts++ if (player == null && controllerFuture != null) { playBeforeConnect = { playTrack(track, audioUrl, queue, startIndex, startPositionMs, sourceName, localUriOverrides) } 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 cf26b6ba0d..64733a7f22 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 @@ -3360,6 +3360,7 @@ class EnhancedPlayerManager private constructor() { audioSessionAnnouncer = null player?.release() player = null + playerFactory.clearPreferences() trackSelector = null appContext = null cacheManager?.release() diff --git a/app/src/main/java/io/github/aedev/flow/player/config/PlayerConfig.kt b/app/src/main/java/io/github/aedev/flow/player/config/PlayerConfig.kt index c56176dbee..7a3eb81d32 100644 --- a/app/src/main/java/io/github/aedev/flow/player/config/PlayerConfig.kt +++ b/app/src/main/java/io/github/aedev/flow/player/config/PlayerConfig.kt @@ -29,14 +29,16 @@ object PlayerConfig { /** Back buffer duration for low-memory devices. */ const val LOW_MEMORY_BACK_BUFFER_DURATION_MS = 0 - /** Hard runtime cap for main-player max buffer */ - const val MAX_SAFE_MAIN_BUFFER_MS = 45_000 + /** The longest max buffer the main player takes, the top of the Custom slider. Bytes stop it sooner. */ + const val MAX_SAFE_MAIN_BUFFER_MS = 180_000 /** Hard runtime cap for main-player min buffer so low-RAM devices do not over-retain media. */ const val MAX_SAFE_MAIN_MIN_BUFFER_MS = 20_000 - /** Runtime caps used when Android reports a small app heap. */ + /** Max buffer cap on a device Android flags as low-RAM. */ const val LOW_MEMORY_MAX_SAFE_MAIN_BUFFER_MS = 18_000 + + /** Min buffer cap when Android reports a small app heap. */ const val LOW_MEMORY_MAX_SAFE_MAIN_MIN_BUFFER_MS = 8_000 const val MAIN_TARGET_BUFFER_BYTES = 32 * 1024 * 1024 @@ -45,6 +47,13 @@ object PlayerConfig { const val LOW_MEMORY_MAIN_TARGET_BUFFER_BYTES = 4 * 1024 * 1024 const val MID_MEMORY_MAIN_TARGET_BUFFER_BYTES = 12 * 1024 * 1024 + /** + * A max buffer longer than the stock profiles may hold up to this share of the app heap, since + * Media3's allocator keeps buffered media in heap arrays: 1/5 of a 256 MB heap is about 51 MB. + */ + const val LONG_BUFFER_HEAP_DIVISOR = 5 + const val MAX_LONG_BUFFER_BYTES = 128 * 1024 * 1024 + /** Explicit target buffer budget per shorts player in the pooled shorts stack. */ const val SHORTS_TARGET_BUFFER_BYTES = 4 * 1024 * 1024 @@ -86,10 +95,10 @@ object PlayerConfig { /** * Media3 holds the current quality while more than this is buffered. At its 25 s default a full - * 45 s buffer drains for 20 s before a collapsed network steps down; the midpoint of the main - * buffer window cuts that to 12.5 s. + * 45 s buffer drains for 20 s before a collapsed network steps down; the midpoint of the 20 s to + * 45 s window it was tuned on cuts that to 12.5 s. */ - const val ABR_MAX_BUFFER_FOR_QUALITY_DECREASE_MS = (MAX_SAFE_MAIN_MIN_BUFFER_MS + MAX_SAFE_MAIN_BUFFER_MS) / 2 + const val ABR_MAX_BUFFER_FOR_QUALITY_DECREASE_MS = 32_500 /** Interval for bandwidth checks during playback (5 seconds) */ const val BANDWIDTH_CHECK_INTERVAL_MS = 5000L diff --git a/app/src/main/java/io/github/aedev/flow/player/factory/LoadControlFactory.kt b/app/src/main/java/io/github/aedev/flow/player/factory/LoadControlFactory.kt index 3567d3cf2f..8d57f06fec 100644 --- a/app/src/main/java/io/github/aedev/flow/player/factory/LoadControlFactory.kt +++ b/app/src/main/java/io/github/aedev/flow/player/factory/LoadControlFactory.kt @@ -8,6 +8,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.upstream.DefaultAllocator import io.github.aedev.flow.data.local.BufferDurations +import io.github.aedev.flow.data.local.BufferProfile import io.github.aedev.flow.player.config.PlayerConfig /** @@ -21,6 +22,8 @@ import io.github.aedev.flow.player.config.PlayerConfig @UnstableApi object LoadControlFactory { private const val TAG = "LoadControlFactory" + private const val BYTES_PER_MB = 1024L * 1024L + private val StockMaxBufferMs = BufferProfile.entries.maxOf { it.maxBuffer } /** * Main video player: stored user preferences, re-capped against what this device's heap can @@ -36,33 +39,7 @@ object LoadControlFactory { ): DefaultLoadControl { val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager val memoryClassMb = activityManager?.memoryClass ?: 256 - val isLowMemoryDevice = activityManager?.isLowRamDevice == true || memoryClassMb <= 256 - val isConstrainedHeap = isLowMemoryDevice || memoryClassMb <= 384 - - val maxSafeMinBufferMs = - if (isLowMemoryDevice) { - PlayerConfig.LOW_MEMORY_MAX_SAFE_MAIN_MIN_BUFFER_MS - } else { - PlayerConfig.MAX_SAFE_MAIN_MIN_BUFFER_MS - } - val maxSafeBufferMs = - if (isLowMemoryDevice) { - PlayerConfig.LOW_MEMORY_MAX_SAFE_MAIN_BUFFER_MS - } else { - PlayerConfig.MAX_SAFE_MAIN_BUFFER_MS - } - val targetBufferBytes = - when { - isLowMemoryDevice -> PlayerConfig.LOW_MEMORY_MAIN_TARGET_BUFFER_BYTES - isConstrainedHeap -> PlayerConfig.MID_MEMORY_MAIN_TARGET_BUFFER_BYTES - else -> PlayerConfig.MAIN_TARGET_BUFFER_BYTES - } - val backBufferMs = - if (isConstrainedHeap) { - PlayerConfig.LOW_MEMORY_BACK_BUFFER_DURATION_MS - } else { - PlayerConfig.BACK_BUFFER_DURATION_MS - } + val budget = videoBudget(memoryClassMb, isLowRamDevice = activityManager?.isLowRamDevice == true, requestedMaxMs = maxMs) val buffers = BufferDurations.sanitize( @@ -70,15 +47,15 @@ object LoadControlFactory { maxMs = maxMs, playbackMs = playbackMs, rebufferMs = rebufferMs, - maxSafeMinMs = maxSafeMinBufferMs, - maxSafeMaxMs = maxSafeBufferMs, + maxSafeMinMs = budget.maxSafeMinMs, + maxSafeMaxMs = budget.maxSafeMaxMs, ) Log.d( TAG, "Buffer config: min=${buffers.minMs}ms, max=${buffers.maxMs}ms, playback=${buffers.playbackMs}ms, " + - "rebuffer=${buffers.rebufferMs}ms, target=${targetBufferBytes / 1024 / 1024}MB, " + - "back=${backBufferMs}ms, heap=${memoryClassMb}MB", + "rebuffer=${buffers.rebufferMs}ms, target=${budget.targetBufferBytes / 1024 / 1024}MB, " + + "back=${budget.backBufferMs}ms, heap=${memoryClassMb}MB", ) return build( @@ -86,9 +63,50 @@ object LoadControlFactory { maxMs = buffers.maxMs, playbackMs = buffers.playbackMs, rebufferMs = buffers.rebufferMs, - backBufferMs = backBufferMs, + backBufferMs = budget.backBufferMs, retainBackBufferFromKeyframe = true, - targetBufferBytes = targetBufferBytes, + targetBufferBytes = budget.targetBufferBytes, + ) + } + + /** What the main video player may buffer on this device, before the user's durations are applied. */ + internal data class VideoBudget( + val maxSafeMinMs: Int, + val maxSafeMaxMs: Int, + val targetBufferBytes: Int, + val backBufferMs: Int, + ) + + /** + * The stock profiles keep the byte targets they always had, so default playback holds no more + * memory than before. Only a Custom max longer than every profile raises the byte target to a + * share of the heap, which is what lets a 120 s or 180 s choice take effect (#1438). A device + * Android flags as low-RAM never gets more than 18 s. + */ + internal fun videoBudget( + memoryClassMb: Int, + isLowRamDevice: Boolean, + requestedMaxMs: Int, + ): VideoBudget { + val isLowMemoryDevice = isLowRamDevice || memoryClassMb <= 256 + val isConstrainedHeap = isLowMemoryDevice || memoryClassMb <= 384 + val tierBytes = + when { + isLowMemoryDevice -> PlayerConfig.LOW_MEMORY_MAIN_TARGET_BUFFER_BYTES + isConstrainedHeap -> PlayerConfig.MID_MEMORY_MAIN_TARGET_BUFFER_BYTES + else -> PlayerConfig.MAIN_TARGET_BUFFER_BYTES + } + val wantsLongBuffer = !isLowRamDevice && requestedMaxMs > StockMaxBufferMs + val longBufferBytes = + (memoryClassMb.toLong() * BYTES_PER_MB / PlayerConfig.LONG_BUFFER_HEAP_DIVISOR) + .coerceAtMost(PlayerConfig.MAX_LONG_BUFFER_BYTES.toLong()) + .toInt() + return VideoBudget( + maxSafeMinMs = + if (isLowMemoryDevice) PlayerConfig.LOW_MEMORY_MAX_SAFE_MAIN_MIN_BUFFER_MS else PlayerConfig.MAX_SAFE_MAIN_MIN_BUFFER_MS, + maxSafeMaxMs = if (isLowRamDevice) PlayerConfig.LOW_MEMORY_MAX_SAFE_MAIN_BUFFER_MS else PlayerConfig.MAX_SAFE_MAIN_BUFFER_MS, + targetBufferBytes = if (wantsLongBuffer) maxOf(tierBytes, longBufferBytes) else tierBytes, + backBufferMs = if (isConstrainedHeap) PlayerConfig.LOW_MEMORY_BACK_BUFFER_DURATION_MS else PlayerConfig.BACK_BUFFER_DURATION_MS, ) } diff --git a/app/src/main/java/io/github/aedev/flow/player/factory/PlayerFactory.kt b/app/src/main/java/io/github/aedev/flow/player/factory/PlayerFactory.kt index d6a90b2aca..37c77f8ee8 100644 --- a/app/src/main/java/io/github/aedev/flow/player/factory/PlayerFactory.kt +++ b/app/src/main/java/io/github/aedev/flow/player/factory/PlayerFactory.kt @@ -64,6 +64,11 @@ class PlayerFactory { return result } + /** Drops the preferences read for the released player, so the next one is built with today's settings. */ + fun clearPreferences() { + cachedPrefs = null + } + suspend fun preloadPreferences(context: Context) { if (cachedPrefs != null) return val prefs = PlayerPreferences(context) diff --git a/app/src/main/java/io/github/aedev/flow/player/stream/PlayabilityVerdict.kt b/app/src/main/java/io/github/aedev/flow/player/stream/PlayabilityVerdict.kt index 47f0a16b7b..a9474fafc6 100644 --- a/app/src/main/java/io/github/aedev/flow/player/stream/PlayabilityVerdict.kt +++ b/app/src/main/java/io/github/aedev/flow/player/stream/PlayabilityVerdict.kt @@ -10,6 +10,7 @@ private val RateLimitReason = Regex("""try again later""", RegexOption.IGNORE_CA private val MembersOrPaidReason = Regex("""members-only|join this channel|requires payment|premium members""", RegexOption.IGNORE_CASE) private const val HAS_DETAILS = "details=yes" +private val LastingBlocks = setOf(PlaybackBlock.REGION, PlaybackBlock.MEMBERS_OR_PAID, PlaybackBlock.LOGIN_REQUIRED) // Clients the ladder skipped without asking say nothing about the video. private val NotAskedReason = Regex("""demoted|no visitorData|PoToken unavailable|not asked""") @@ -93,6 +94,31 @@ internal object PlayabilityVerdict { else -> null } + /** + * Whether a failed play stays failed for this viewer, so the video may be hidden: it is gone, or + * refused for the viewer's country, a membership, or a sign-in this app cannot offer. Walls and + * rate limits pass, so they never hide anything. + */ + fun isLasting( + isGone: Boolean, + block: PlaybackBlock?, + ): Boolean = isGone || block in LastingBlocks + + /** + * Whether one `/player` answer, asked in English, refuses the video to this viewer for their + * country or a membership. One client's ERROR is no proof the video is gone, and a plain + * UNPLAYABLE is how app clients refuse label and kids videos the web clients still serve, so + * neither counts. + */ + fun refusesViewer( + status: PlayerResponse.PlayabilityStatus, + hasVideoDetails: Boolean, + ): Boolean = + when (viewerBlock(refusal(status, hasVideoDetails))) { + PlaybackBlock.REGION, PlaybackBlock.MEMBERS_OR_PAID -> true + else -> false + } + /** Every client that answered was bot-walled, so the refusal is about this viewer only. */ fun everyClientWalled(failureReasons: List): Boolean { val answered = failureReasons.filterNot { NotAskedReason.containsMatchIn(it) || NoAnswerReason.containsMatchIn(it) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadDialogCompact.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadDialogCompact.kt index 8e18e2779a..144430d426 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadDialogCompact.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadDialogCompact.kt @@ -150,14 +150,19 @@ fun MediaDownloadDialogCompact( ?: codecsForHeight.firstOrNull() ?: "", ) } - var selectedAudioIndex by remember(audioStreams, lastAudioLabel) { + var selectedAudioIndex by remember(audioStreams, lastAudioLabel, preferredLang) { mutableStateOf( - audioStreams.indexOfFirst { audioOptionLabel(it, audioLabelStrings) == lastAudioLabel }.takeIf { it >= 0 } - ?: audioStreams.indices.maxByOrNull { DownloadStreamPolicy.audioBitrateKbps(audioStreams[it]) } - ?: 0, + DownloadStreamPolicy.defaultAudioIndex(audioStreams, preferredLang, lastAudioLabel) { + audioOptionLabel(it, audioLabelStrings) + }, ) } + val audioTracks = remember(audioStreams) { DownloadStreamPolicy.audioTrackChoices(audioStreams) } + var videoAudio by remember(audioStreams, preferredLang) { + mutableStateOf(DownloadStreamPolicy.pickAacAudio(audioStreams, preferredLang)) + } + val selectedSizeText = approxDownloadSizeLabel(streamSizes[VideoCodecUtils.streamSizeKey(selectedHeight, selectedCodec)]) fun confirmDownload() { @@ -185,7 +190,7 @@ fun MediaDownloadDialogCompact( DownloadStreamPolicy.videoHeight(it) == selectedHeight && DownloadStreamPolicy.videoCodecKey(it) == selectedCodec } ?: return - val audio = DownloadStreamPolicy.pickAacAudio(audioStreams, preferredLang) + val audio = videoAudio if (audio == null) { Toast.makeText(context, context.getString(R.string.download_no_compatible_audio), Toast.LENGTH_LONG).show() return @@ -280,6 +285,17 @@ fun MediaDownloadDialogCompact( codecOptionLabel(codec, separatorDot) to { selectedCodec = codec } }, ) + if (audioTracks.size > 1) { + Spacer(Modifier.height(10.dp)) + DownloadDropdownRow( + label = stringResource(R.string.audio_track), + value = videoAudio?.let(DownloadStreamPolicy::audioLanguageLabel).orEmpty(), + options = + audioTracks.map { track -> + DownloadStreamPolicy.audioLanguageLabel(track).orEmpty() to { videoAudio = track } + }, + ) + } if (subtitleOptions.isNotEmpty()) { Spacer(Modifier.height(10.dp)) DownloadSubtitleRow( diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/PlaybackSessionApplier.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/PlaybackSessionApplier.kt index 7218fa13c1..9ba995757d 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/player/PlaybackSessionApplier.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/PlaybackSessionApplier.kt @@ -20,6 +20,7 @@ import io.github.aedev.flow.player.GlobalPlayerState import io.github.aedev.flow.player.audio.LoudnessGain import io.github.aedev.flow.player.error.VideoErrorMapper import io.github.aedev.flow.player.stream.InnerTubeVideoStreamExtractor +import io.github.aedev.flow.player.stream.PlayabilityVerdict import io.github.aedev.flow.player.stream.PlaybackFailure import io.github.aedev.flow.player.stream.ResolvedCaption import io.github.aedev.flow.player.stream.ResolvedPlayback @@ -242,7 +243,13 @@ internal class PlaybackSessionApplier( PlaybackFailure.TIMEOUT -> VideoErrorMapper.fromTimeout(context) else -> VideoErrorMapper.from(context, step.cause, load.videoId) } - if (step.failure == PlaybackFailure.UNEXPECTED && !videoError.isRetryable) { + val lastingRefusal = + step.failure == PlaybackFailure.EXTRACTION && + PlayabilityVerdict.isLasting( + InnerTubeVideoStreamExtractor.isGone(load.videoId), + InnerTubeVideoStreamExtractor.blockOf(load.videoId), + ) + if ((step.failure == PlaybackFailure.UNEXPECTED && !videoError.isRetryable) || lastingRefusal) { playerPreferences.markVideoUnplayable(load.videoId) } // A removed or unplayable video stays that way, so a playlist carries on instead of stopping on it. diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/BufferSettingsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/BufferSettingsScreen.kt index 9b2a9561f2..146c798aa0 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/BufferSettingsScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/BufferSettingsScreen.kt @@ -7,7 +7,9 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.aedev.flow.R import io.github.aedev.flow.data.local.BufferProfile +import io.github.aedev.flow.player.config.PlayerConfig import io.github.aedev.flow.ui.components.settings.SettingEntry +import io.github.aedev.flow.ui.components.settings.SettingsFootnote import io.github.aedev.flow.ui.components.settings.SettingsGroupScope import io.github.aedev.flow.ui.components.settings.SettingsPage import io.github.aedev.flow.ui.components.settings.option @@ -19,6 +21,10 @@ import kotlin.math.roundToInt private const val MS_PER_SECOND = 1000f private val PresetProfiles = listOf(BufferProfile.STABLE, BufferProfile.AGGRESSIVE, BufferProfile.DATASAVER) +// The ranges end where the player's caps do, so a slider never offers a value it would ignore (#1438). +private val MinBufferRange = 1_000..PlayerConfig.MAX_SAFE_MAIN_MIN_BUFFER_MS +private val MaxBufferRange = 30_000..PlayerConfig.MAX_SAFE_MAIN_BUFFER_MS + /** How much video the player holds ahead: a preset, or the four durations set by hand. */ @Composable internal fun BufferSettingsScreen( @@ -58,12 +64,16 @@ internal fun BufferSettingsScreen( onClick = { viewModel.setProfile(BufferProfile.CUSTOM) }, ) if (profile == BufferProfile.CUSTOM) { - millisSlider(BufferIndex.minBuffer, minBuffer, 1_000..60_000, 1_000, viewModel::setMinBuffer) - millisSlider(BufferIndex.maxBuffer, maxBuffer, 30_000..180_000, 5_000, viewModel::setMaxBuffer) + millisSlider(BufferIndex.minBuffer, minBuffer, MinBufferRange, 1_000, viewModel::setMinBuffer) + millisSlider(BufferIndex.maxBuffer, maxBuffer, MaxBufferRange, 5_000, viewModel::setMaxBuffer) millisSlider(BufferIndex.startBuffer, startBuffer, 500..5_000, 500, viewModel::setStartBuffer) millisSlider(BufferIndex.rebuffer, rebuffer, 1_000..10_000, 500, viewModel::setRebuffer) } } + if (profile == BufferProfile.CUSTOM) { + item("buffer.custom.memory") { SettingsFootnote(stringResource(R.string.buffer_long_memory_note)) } + } + item("buffer.restart") { SettingsFootnote(stringResource(R.string.cache_size_applies_on_restart)) } } } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/subscriptions/SubscriptionPlayerMetadata.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/subscriptions/SubscriptionPlayerMetadata.kt index 3b68a6a8d7..9782471ffa 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/subscriptions/SubscriptionPlayerMetadata.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/subscriptions/SubscriptionPlayerMetadata.kt @@ -3,65 +3,92 @@ package io.github.aedev.flow.ui.screens.subscriptions import io.github.aedev.flow.data.model.Video import io.github.aedev.flow.innertube.YouTube import io.github.aedev.flow.innertube.models.YouTubeClient +import io.github.aedev.flow.innertube.models.YouTubeLocale +import io.github.aedev.flow.innertube.models.response.PlayerResponse +import io.github.aedev.flow.player.stream.PlayabilityVerdict import io.github.aedev.flow.utils.ThumbnailUrlResolver import io.github.aedev.flow.utils.premiereDateText import kotlinx.coroutines.withTimeoutOrNull +/** What one player lookup told the feed about a row. */ +internal data class SubscriptionPlayerLookup( + val videoId: String, + /** The row with what RSS cannot carry; null when the answer told nothing new. */ + val enriched: Video?, + /** YouTube refuses the video to this viewer for their country or a membership (#1443). */ + val refusedToViewer: Boolean, +) + /** - * The duration, live state and scheduled start RSS cannot carry, read from the player endpoint. - * Null when the lookup times out or tells nothing new. + * The duration, live state and scheduled start RSS cannot carry, read from the player endpoint, + * and whether that endpoint refuses the video to this viewer. RSS lists uploads the viewer's + * country cannot play, which YouTube's own Videos tab hides. Null when the lookup times out. + * + * Asked in English so [PlayabilityVerdict] can read the refusal; `/player` titles are never + * translated, so the cards keep their original titles. */ internal suspend fun fetchSubscriptionPlayerMetadata( video: Video, timeoutMs: Long, -): Video? = +): SubscriptionPlayerLookup? = withTimeoutOrNull(timeoutMs) { val response = - YouTube.player(video.id, client = YouTubeClient.ANDROID).getOrNull() - ?: YouTube.player(video.id, client = YouTubeClient.MOBILE).getOrNull() + YouTube.player(video.id, client = YouTubeClient.ANDROID, localeOverride = YouTubeLocale.EXTRACTION).getOrNull() + ?: YouTube.player(video.id, client = YouTubeClient.MOBILE, localeOverride = YouTubeLocale.EXTRACTION).getOrNull() ?: return@withTimeoutOrNull null - val details = response.videoDetails ?: return@withTimeoutOrNull null - // The feed only knows the day the stream was announced; the player endpoint knows - // the day it starts, and until then "live content" is a scheduled stream, not a live one. - val scheduledStartMs = - response.playabilityStatus.liveStreamability - ?.liveStreamabilityRenderer - ?.offlineSlate - ?.liveStreamOfflineSlateRenderer - ?.scheduledStartTime - ?.toLongOrNull() - ?.times(1000L) - ?.takeIf { it > System.currentTimeMillis() } - val isUpcoming = - scheduledStartMs != null || - response.playabilityStatus.status.equals("LIVE_STREAM_OFFLINE", ignoreCase = true) - val isLive = !isUpcoming && (details.isLive == true || details.isLiveContent == true) - val duration = details.lengthSeconds.toIntOrNull()?.takeIf { it > 0 } ?: 0 - if (!isUpcoming && !isLive && duration <= 0) return@withTimeoutOrNull null - - val bestThumbnail = - details.thumbnail - ?.thumbnails - ?.maxByOrNull { (it.width ?: 0) * (it.height ?: 0) } - ?.url - ?.let { ThumbnailUrlResolver.normalizeVideoThumbnail(video.id, it) } - ?: ThumbnailUrlResolver.normalizeVideoThumbnail(video.id, video.thumbnailUrl) - - video.copy( - title = details.title?.takeIf { it.isNotBlank() } ?: video.title, - channelName = details.author?.takeIf { it.isNotBlank() } ?: video.channelName, - channelId = details.channelId.takeIf { it.isNotBlank() } ?: video.channelId, - thumbnailUrl = bestThumbnail, - duration = if (isLive || isUpcoming) 0 else duration, - viewCount = maxOf(video.viewCount, details.viewCount?.toLongOrNull() ?: 0L), - isLive = isLive || (!isUpcoming && video.isLive), - isUpcoming = isUpcoming, - isScheduledLive = isUpcoming && details.isLiveContent == true, - timestamp = scheduledStartMs ?: video.timestamp, - uploadDate = scheduledStartMs?.let(::premiereDateText) ?: video.uploadDate, + SubscriptionPlayerLookup( + videoId = video.id, + enriched = enrichedRow(video, response), + refusedToViewer = PlayabilityVerdict.refusesViewer(response.playabilityStatus, response.videoDetails != null), ) } +private fun enrichedRow( + video: Video, + response: PlayerResponse, +): Video? { + val details = response.videoDetails ?: return null + // The feed only knows the day the stream was announced; the player endpoint knows + // the day it starts, and until then "live content" is a scheduled stream, not a live one. + val scheduledStartMs = + response.playabilityStatus.liveStreamability + ?.liveStreamabilityRenderer + ?.offlineSlate + ?.liveStreamOfflineSlateRenderer + ?.scheduledStartTime + ?.toLongOrNull() + ?.times(1000L) + ?.takeIf { it > System.currentTimeMillis() } + val isUpcoming = + scheduledStartMs != null || + response.playabilityStatus.status.equals("LIVE_STREAM_OFFLINE", ignoreCase = true) + val isLive = !isUpcoming && (details.isLive == true || details.isLiveContent == true) + val duration = details.lengthSeconds.toIntOrNull()?.takeIf { it > 0 } ?: 0 + if (!isUpcoming && !isLive && duration <= 0) return null + + val bestThumbnail = + details.thumbnail + ?.thumbnails + ?.maxByOrNull { (it.width ?: 0) * (it.height ?: 0) } + ?.url + ?.let { ThumbnailUrlResolver.normalizeVideoThumbnail(video.id, it) } + ?: ThumbnailUrlResolver.normalizeVideoThumbnail(video.id, video.thumbnailUrl) + + return video.copy( + title = details.title?.takeIf { it.isNotBlank() } ?: video.title, + channelName = details.author?.takeIf { it.isNotBlank() } ?: video.channelName, + channelId = details.channelId.takeIf { it.isNotBlank() } ?: video.channelId, + thumbnailUrl = bestThumbnail, + duration = if (isLive || isUpcoming) 0 else duration, + viewCount = maxOf(video.viewCount, details.viewCount?.toLongOrNull() ?: 0L), + isLive = isLive || (!isUpcoming && video.isLive), + isUpcoming = isUpcoming, + isScheduledLive = isUpcoming && details.isLiveContent == true, + timestamp = scheduledStartMs ?: video.timestamp, + uploadDate = scheduledStartMs?.let(::premiereDateText) ?: video.uploadDate, + ) +} + /** Folds a player lookup into the feed row it was made for, keeping what the lookup left blank. */ internal fun Video.withPlayerMetadata(enriched: Video): Video = copy( diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/subscriptions/SubscriptionsViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/subscriptions/SubscriptionsViewModel.kt index 565c16123d..498b2f3b60 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/subscriptions/SubscriptionsViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/subscriptions/SubscriptionsViewModel.kt @@ -397,7 +397,7 @@ class SubscriptionsViewModel try { val enrichedById = mutableMapOf() candidates.chunked(DURATION_ENRICHMENT_BATCH_SIZE).forEach { batch -> - val enrichedBatch = + val lookups = supervisorScope { batch .map { video -> @@ -405,14 +405,15 @@ class SubscriptionsViewModel fetchSubscriptionPlayerMetadata(video, DURATION_METADATA_TIMEOUT_MS) } }.awaitAll() - } + }.filterNotNull() withContext(Dispatchers.Main.immediate) { val attemptedAt = System.currentTimeMillis() batch.forEach { video -> durationEnrichmentAttemptedAt[video.id] = attemptedAt } } - enrichedBatch.filterNotNull().associateByTo(enrichedById) { it.id } + lookups.filter { it.refusedToViewer }.forEach { playerPreferences.markVideoUnplayable(it.videoId) } + lookups.mapNotNull { it.enriched }.associateByTo(enrichedById) { it.id } } if (enrichedById.isEmpty()) return@launch diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8cd2d79342..ed1a19a795 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -237,6 +237,7 @@ This video is less than 2 minutes long. Would you like to switch to a specialized player? Shorts Player Music Player + Music can\'t play. Flow\'s music service is turned off on this device. %d channel %d channels @@ -1483,6 +1484,7 @@ Smaller buffers that use less data Manually configure buffer sizes Larger buffers stall less but take longer to start. + Long buffers stop early when memory runs low. Switch to \'Custom\' to enable sliders. Min Buffer: %1$ds Max Buffer: %1$ds diff --git a/app/src/test/java/io/github/aedev/flow/data/video/DownloadStreamPolicyTest.kt b/app/src/test/java/io/github/aedev/flow/data/video/DownloadStreamPolicyTest.kt index 70eaf66058..a2dd8f40aa 100644 --- a/app/src/test/java/io/github/aedev/flow/data/video/DownloadStreamPolicyTest.kt +++ b/app/src/test/java/io/github/aedev/flow/data/video/DownloadStreamPolicyTest.kt @@ -193,6 +193,75 @@ class DownloadStreamPolicyTest { .inOrder() } + @Test + fun `the audio list opens on the original track, then the dubs by language`() { + val ar = audio(140, averageBitrate = 129_483, trackId = "ar.3", trackName = "Arabic") + val enLow = audio(139, averageBitrate = 48_000, trackId = "en.4", trackName = "English original") + val en = audio(140, averageBitrate = 129_477, trackId = "en.4", trackName = "English original") + val fr = audio(140, averageBitrate = 129_479, trackId = "fr.3", trackName = "French") + + assertThat(DownloadStreamPolicy.buildDownloadAudioFormats(listOf(fr, ar, enLow, en))) + .containsExactly(en, enLow, ar, fr) + .inOrder() + } + + @Test + fun `two tracks in one language keep their own rows together`() { + val hans = audio(140, averageBitrate = 129_000, trackId = "zh-Hans.3", trackName = "Chinese (Simplified)") + val hansLow = audio(139, averageBitrate = 48_000, trackId = "zh-Hans.3", trackName = "Chinese (Simplified)") + val hant = audio(140, averageBitrate = 129_000, trackId = "zh-Hant.3", trackName = "Chinese (Traditional)") + val hantLow = audio(139, averageBitrate = 48_000, trackId = "zh-Hant.3", trackName = "Chinese (Traditional)") + + assertThat(DownloadStreamPolicy.buildDownloadAudioFormats(listOf(hant, hansLow, hantLow, hans))) + .containsExactly(hans, hansLow, hant, hantLow) + .inOrder() + } + + @Test + fun `a video download offers each track once, at its best aac, original first`() { + val en = audio(140, averageBitrate = 129_477, trackId = "en.4", trackName = "English original") + val enLow = audio(139, averageBitrate = 48_000, trackId = "en.4", trackName = "English original") + val ar = audio(140, averageBitrate = 129_483, trackId = "ar.3", trackName = "Arabic") + val arLow = audio(139, averageBitrate = 48_000, trackId = "ar.3", trackName = "Arabic") + val opus = audio(251, mimeType = OPUS, bitrate = 160_000, trackId = "ar.3", trackName = "Arabic") + + val choices = + DownloadStreamPolicy.audioTrackChoices( + DownloadStreamPolicy.buildDownloadAudioFormats(listOf(arLow, opus, ar, enLow, en)), + ) + + assertThat(choices).containsExactly(en, ar).inOrder() + assertThat(DownloadStreamPolicy.audioTrackChoices(listOf(audio(140, bitrate = 128_000), audio(139, bitrate = 48_000)))).hasSize(1) + } + + @Test + fun `an audio-only download defaults to the original when every dub shares its bitrate`() { + val streams = + DownloadStreamPolicy.buildDownloadAudioFormats( + listOf( + audio(140, averageBitrate = 129_483, trackId = "ar.3", trackName = "Arabic"), + audio(140, averageBitrate = 129_477, trackId = "en.4", trackName = "English original"), + ), + ) + + val index = DownloadStreamPolicy.defaultAudioIndex(streams, preferredLang = "", rememberedLabel = null) { "" } + + assertThat(streams[index].audioTrack?.id).isEqualTo("en.4") + } + + @Test + fun `a remembered dub does not become the default for the next video`() { + val ar = audio(140, averageBitrate = 129_000, trackId = "ar.3", trackName = "Arabic") + val en = audio(140, averageBitrate = 129_000, trackId = "en.4", trackName = "English original") + val enLow = audio(139, averageBitrate = 48_000, trackId = "en.4", trackName = "English original") + val streams = listOf(en, enLow, ar) + val labels = mapOf(en to "M4A 129 en", enLow to "M4A 48 en", ar to "M4A 129 ar") + + assertThat(DownloadStreamPolicy.defaultAudioIndex(streams, "", "M4A 129 ar") { labels.getValue(it) }).isEqualTo(0) + assertThat(DownloadStreamPolicy.defaultAudioIndex(streams, "", "M4A 48 en") { labels.getValue(it) }).isEqualTo(1) + assertThat(DownloadStreamPolicy.defaultAudioIndex(streams, "ar", "M4A 129 ar") { labels.getValue(it) }).isEqualTo(2) + } + @Test fun `aac is taken even when opus has the higher bitrate`() { val opus = audio(251, mimeType = OPUS, bitrate = 160_000) diff --git a/app/src/test/java/io/github/aedev/flow/data/video/downloader/resolve/DownloadStreamResolverTest.kt b/app/src/test/java/io/github/aedev/flow/data/video/downloader/resolve/DownloadStreamResolverTest.kt index 2f007f0c05..33408a33ff 100644 --- a/app/src/test/java/io/github/aedev/flow/data/video/downloader/resolve/DownloadStreamResolverTest.kt +++ b/app/src/test/java/io/github/aedev/flow/data/video/downloader/resolve/DownloadStreamResolverTest.kt @@ -6,6 +6,7 @@ import io.github.aedev.flow.data.video.downloader.request.DownloadRequest import io.github.aedev.flow.data.video.downloader.tags.DownloadKind import io.github.aedev.flow.data.video.downloader.tags.DownloadTags import io.github.aedev.flow.innertube.models.response.PlayerResponse.StreamingData.Format +import io.github.aedev.flow.innertube.models.response.PlayerResponse.StreamingData.Format.AudioTrack import io.github.aedev.flow.player.stream.InnerTubeVideoStreamExtractor.VideoExtractionResult import io.mockk.every import io.mockk.mockk @@ -21,6 +22,7 @@ class DownloadStreamResolverTest { videoCodec: String? = null, videoItag: Int? = null, audioItag: Int? = null, + audioTrackId: String? = null, ) = DownloadRequest( tags = DownloadTags(kind = kind, videoId = "v", title = "t"), audioOnly = audioOnly, @@ -28,6 +30,7 @@ class DownloadStreamResolverTest { videoCodec = videoCodec, videoItag = videoItag, audioItag = audioItag, + audioTrackId = audioTrackId, ) private fun result( @@ -93,6 +96,21 @@ class DownloadStreamResolverTest { assertThat(picked.video?.itag).isNotEqualTo(399) } + @Test + fun `a picked dub keeps its track although every dub shares the itag`() { + val dubs = listOf(dub("ar.3", "Arabic", 129_483), dub("en.4", "English original", 129_477), dub("es.3", "Spanish", 129_480)) + + assertThat(streams(select(request(audioItag = 140, audioTrackId = "en.4"), audio = dubs)).audio.audioTrack?.id).isEqualTo("en.4") + assertThat(streams(select(request(audioItag = 140, audioTrackId = "es.3"), audio = dubs)).audio.audioTrack?.id).isEqualTo("es.3") + } + + @Test + fun `a track that is gone falls back to the original, not to the first dub`() { + val dubs = listOf(dub("ar.3", "Arabic", 129_483), dub("en.4", "English original", 129_477)) + + assertThat(streams(select(request(audioItag = 140, audioTrackId = "fr.3"), audio = dubs)).audio.audioTrack?.id).isEqualTo("en.4") + } + @Test fun `songs and audio-only downloads fetch no video`() { assertThat(streams(select(request(kind = DownloadKind.MUSIC))).video).isNull() @@ -138,6 +156,12 @@ class DownloadStreamResolverTest { signatureCipher = null, ) + fun dub( + id: String, + name: String, + bitrate: Int, + ) = format(140, "audio/mp4; codecs=\"mp4a.40.2\"", bitrate = bitrate).copy(audioTrack = AudioTrack(displayName = name, id = id)) + val AAC = format(140, "audio/mp4; codecs=\"mp4a.40.2\"", bitrate = 128_000) val OPUS = format(251, "audio/webm; codecs=\"opus\"", bitrate = 160_000) val LADDER = diff --git a/app/src/test/java/io/github/aedev/flow/player/factory/LoadControlFactoryTest.kt b/app/src/test/java/io/github/aedev/flow/player/factory/LoadControlFactoryTest.kt index 800dd459d0..539b94c5bc 100644 --- a/app/src/test/java/io/github/aedev/flow/player/factory/LoadControlFactoryTest.kt +++ b/app/src/test/java/io/github/aedev/flow/player/factory/LoadControlFactoryTest.kt @@ -3,6 +3,7 @@ package io.github.aedev.flow.player.factory import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl import io.github.aedev.flow.player.config.PlayerConfig +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -47,6 +48,35 @@ class LoadControlFactoryTest { ) } + @Test + fun `the stock profiles keep the byte targets they always had`() { + fun bytes(memoryClassMb: Int) = + LoadControlFactory.videoBudget(memoryClassMb, isLowRamDevice = false, requestedMaxMs = 50_000).targetBufferBytes + + assertEquals(PlayerConfig.LOW_MEMORY_MAIN_TARGET_BUFFER_BYTES, bytes(256)) + assertEquals(PlayerConfig.MID_MEMORY_MAIN_TARGET_BUFFER_BYTES, bytes(384)) + assertEquals(PlayerConfig.MAIN_TARGET_BUFFER_BYTES, bytes(512)) + } + + @Test + fun `a long custom buffer is honoured up to a fifth of the heap`() { + val pixelHeap = LoadControlFactory.videoBudget(memoryClassMb = 256, isLowRamDevice = false, requestedMaxMs = 180_000) + val bigHeap = LoadControlFactory.videoBudget(memoryClassMb = 1024, isLowRamDevice = false, requestedMaxMs = 180_000) + + assertEquals(180_000, pixelHeap.maxSafeMaxMs) + assertEquals(256 * MB / 5, pixelHeap.targetBufferBytes) + assertEquals(PlayerConfig.MAX_LONG_BUFFER_BYTES, bigHeap.targetBufferBytes) + assertEquals(PlayerConfig.LOW_MEMORY_MAX_SAFE_MAIN_MIN_BUFFER_MS, pixelHeap.maxSafeMinMs) + } + + @Test + fun `a low-RAM device keeps its short buffer whatever was asked`() { + val budget = LoadControlFactory.videoBudget(memoryClassMb = 192, isLowRamDevice = true, requestedMaxMs = 180_000) + + assertEquals(PlayerConfig.LOW_MEMORY_MAX_SAFE_MAIN_BUFFER_MS, budget.maxSafeMaxMs) + assertEquals(PlayerConfig.LOW_MEMORY_MAIN_TARGET_BUFFER_BYTES, budget.targetBufferBytes) + } + @Test(expected = IllegalArgumentException::class) fun `the load control still rejects a profile that inverts min and rebuffer`() { // Keeps the checks above from passing vacuously. @@ -70,4 +100,8 @@ class LoadControlFactoryTest { DefaultLoadControl.Builder().setBufferDurationsMs(minMs, maxMs, playbackMs, rebufferMs) } + + private companion object { + const val MB = 1024 * 1024 + } } diff --git a/app/src/test/java/io/github/aedev/flow/player/stream/PlayabilityVerdictTest.kt b/app/src/test/java/io/github/aedev/flow/player/stream/PlayabilityVerdictTest.kt index 2b4e31cffc..7f5fc9f228 100644 --- a/app/src/test/java/io/github/aedev/flow/player/stream/PlayabilityVerdictTest.kt +++ b/app/src/test/java/io/github/aedev/flow/player/stream/PlayabilityVerdictTest.kt @@ -152,6 +152,34 @@ class PlayabilityVerdictTest { assertThat(PlayabilityVerdict.block(members)).isEqualTo(PlaybackBlock.MEMBERS_OR_PAID) } + @Test + fun `a gone video and a country, membership or sign-in refusal stay failed, a wall does not`() { + assertThat(PlayabilityVerdict.isLasting(isGone = true, block = null)).isTrue() + listOf(PlaybackBlock.REGION, PlaybackBlock.MEMBERS_OR_PAID, PlaybackBlock.LOGIN_REQUIRED).forEach { block -> + assertThat(PlayabilityVerdict.isLasting(isGone = false, block = block)).isTrue() + } + listOf(PlaybackBlock.BOT_WALL, PlaybackBlock.NETWORK_WALL, PlaybackBlock.RATE_LIMITED, null).forEach { block -> + assertThat(PlayabilityVerdict.isLasting(isGone = false, block = block)).isFalse() + } + } + + @Test + fun `one answer refuses the viewer only for their country or a membership`() { + fun refuses( + status: String, + reason: String?, + ) = PlayabilityVerdict.refusesViewer(PlayerResponse.PlayabilityStatus(status = status, reason = reason), hasVideoDetails = true) + + assertThat(refuses("UNPLAYABLE", "The uploader has not made this video available in your country")).isTrue() + assertThat(refuses("UNPLAYABLE", "Join this channel to get access to members-only content like this video")).isTrue() + assertThat(refuses("UNPLAYABLE", "Playback on other apps has been disabled by the video owner")).isFalse() + assertThat(refuses("ERROR", "Video unavailable")).isFalse() + assertThat(refuses("LOGIN_REQUIRED", "Sign in to confirm your age")).isFalse() + assertThat(refuses("LOGIN_REQUIRED", "Sign in to confirm you're not a bot")).isFalse() + assertThat(refuses("UNPLAYABLE", "This content isn't available, try again later")).isFalse() + assertThat(refuses("OK", null)).isFalse() + } + @Test fun `a refusal is written with its subreason and whether details came back`() { val status = diff --git a/app/src/test/java/io/github/aedev/flow/ui/screens/player/PlaybackSessionApplierTest.kt b/app/src/test/java/io/github/aedev/flow/ui/screens/player/PlaybackSessionApplierTest.kt index 6a53c93744..11bfd13f8c 100644 --- a/app/src/test/java/io/github/aedev/flow/ui/screens/player/PlaybackSessionApplierTest.kt +++ b/app/src/test/java/io/github/aedev/flow/ui/screens/player/PlaybackSessionApplierTest.kt @@ -13,6 +13,8 @@ import io.github.aedev.flow.innertube.models.response.PlayerResponse import io.github.aedev.flow.player.GlobalPlayerState import io.github.aedev.flow.player.stream.CaptionTrackResolver import io.github.aedev.flow.player.stream.InnerTubeVideoStreamExtractor +import io.github.aedev.flow.player.stream.PlaybackBlock +import io.github.aedev.flow.player.stream.PlaybackBlockedException import io.github.aedev.flow.player.stream.PlaybackFailure import io.github.aedev.flow.player.stream.ResolvedPlayback import io.github.aedev.flow.ui.screens.player.VideoPlayerViewModelHarness.Companion.video @@ -242,6 +244,45 @@ class PlaybackSessionApplierTest { coVerify(exactly = 0) { harness.playerPreferences.markVideoUnplayable(any()) } } + @Test + fun `a region-blocked video is marked unplayable`() = + runTest(testDispatcher) { + every { InnerTubeVideoStreamExtractor.blockOf(VIDEO_ID) } returns PlaybackBlock.REGION + + applier().apply( + ResolvedPlayback.Failed(PlaybackFailure.EXTRACTION, PlaybackBlockedException(PlaybackBlock.REGION), emptyList()), + load(), + ) + advanceUntilIdle() + + coVerify(exactly = 1) { harness.playerPreferences.markVideoUnplayable(VIDEO_ID) } + } + + @Test + fun `a bot wall leaves the video unmarked`() = + runTest(testDispatcher) { + every { InnerTubeVideoStreamExtractor.blockOf(VIDEO_ID) } returns PlaybackBlock.BOT_WALL + + applier().apply( + ResolvedPlayback.Failed(PlaybackFailure.EXTRACTION, PlaybackBlockedException(PlaybackBlock.BOT_WALL), emptyList()), + load(), + ) + advanceUntilIdle() + + coVerify(exactly = 0) { harness.playerPreferences.markVideoUnplayable(any()) } + } + + @Test + fun `a removed video is marked although no block is recorded`() = + runTest(testDispatcher) { + every { InnerTubeVideoStreamExtractor.isGone(VIDEO_ID) } returns true + + applier().apply(ResolvedPlayback.Failed(PlaybackFailure.EXTRACTION, null, emptyList()), load()) + advanceUntilIdle() + + coVerify(exactly = 1) { harness.playerPreferences.markVideoUnplayable(VIDEO_ID) } + } + @Test fun `a step from a superseded load writes nothing`() = runTest(testDispatcher) { diff --git a/app/src/test/java/io/github/aedev/flow/ui/screens/subscriptions/SubscriptionPlayerMetadataTest.kt b/app/src/test/java/io/github/aedev/flow/ui/screens/subscriptions/SubscriptionPlayerMetadataTest.kt new file mode 100644 index 0000000000..983a0529fe --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/ui/screens/subscriptions/SubscriptionPlayerMetadataTest.kt @@ -0,0 +1,93 @@ +package io.github.aedev.flow.ui.screens.subscriptions + +import com.google.common.truth.Truth.assertThat +import io.github.aedev.flow.innertube.YouTube +import io.github.aedev.flow.innertube.models.ResponseContext +import io.github.aedev.flow.innertube.models.YouTubeClient +import io.github.aedev.flow.innertube.models.YouTubeLocale +import io.github.aedev.flow.innertube.models.response.PlayerResponse +import io.github.aedev.flow.innertube.models.response.PlayerResponse.PlayabilityStatus +import io.github.aedev.flow.ui.screens.player.VideoPlayerViewModelHarness.Companion.video +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +class SubscriptionPlayerMetadataTest { + @Before + fun setUp() { + mockkObject(YouTube) + } + + @After + fun tearDown() { + unmockkObject(YouTube) + } + + private fun answer(status: PlayabilityStatus) { + coEvery { YouTube.player(any(), any(), YouTubeClient.ANDROID, any(), any(), any(), any()) } returns + Result.success(response(status)) + } + + @Test + fun `a country refusal is reported and the row still gets its duration`() = + runTest { + answer(REGION) + + val lookup = fetchSubscriptionPlayerMetadata(video("v1"), TIMEOUT_MS) + + assertThat(lookup?.refusedToViewer).isTrue() + assertThat(lookup?.enriched?.duration).isEqualTo(70) + coVerify { YouTube.player("v1", any(), YouTubeClient.ANDROID, any(), any(), YouTubeLocale.EXTRACTION, any()) } + } + + @Test + fun `a playable video and a plain unplayable one are not refused`() = + runTest { + answer(PlayabilityStatus(status = "OK", reason = null)) + assertThat(fetchSubscriptionPlayerMetadata(video("v1"), TIMEOUT_MS)?.refusedToViewer).isFalse() + + answer(PlayabilityStatus(status = "UNPLAYABLE", reason = "Playback on other apps has been disabled by the video owner")) + assertThat(fetchSubscriptionPlayerMetadata(video("v1"), TIMEOUT_MS)?.refusedToViewer).isFalse() + } + + private companion object { + const val TIMEOUT_MS = 5_000L + + val REGION = + PlayabilityStatus( + status = "UNPLAYABLE", + reason = "Video unavailable", + errorScreen = + PlayabilityStatus.ErrorScreen( + PlayabilityStatus.ErrorScreen.PlayerErrorMessageRenderer( + subreason = + PlayerResponse.Captions.Text( + simpleText = "The uploader has not made this video available in your country", + ), + ), + ), + ) + + fun response(status: PlayabilityStatus) = + PlayerResponse( + responseContext = ResponseContext(visitorData = null, serviceTrackingParams = null), + playabilityStatus = status, + playerConfig = null, + streamingData = null, + videoDetails = + PlayerResponse.VideoDetails( + videoId = "v1", + title = "Original title", + author = "Channel", + channelId = "UC1", + lengthSeconds = "70", + ), + playbackTracking = null, + ) + } +}