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,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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Format>): List<Format> =
formats
Expand All @@ -107,8 +109,10 @@ object DownloadStreamPolicy {
format.isOriginal.toString(),
).joinToString("|")
}.sortedWith(
compareByDescending<Format> { audioBitrateKbps(it) }
.thenBy { audioLocale(it)?.displayLanguage.orEmpty() },
compareByDescending<Format> { it.isOriginal }
.thenBy { audioLocale(it)?.displayLanguage.orEmpty() }
.thenBy { it.audioTrack?.id.orEmpty() }
.thenByDescending { audioBitrateKbps(it) },
)

/**
Expand Down Expand Up @@ -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<Format>): List<Format> =
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<Format>,
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -305,8 +323,7 @@ object EnhancedMusicPlayerManager {
e.printStackTrace()
}
}, MoreExecutors.directExecutor())

startPositionUpdates()
return true
}

private fun setupPlayerListener(controller: Player) {
Expand Down Expand Up @@ -639,6 +656,11 @@ object EnhancedMusicPlayerManager {
sourceName: String? = null,
localUriOverrides: Map<String, Uri> = 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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3360,6 +3360,7 @@ class EnhancedPlayerManager private constructor() {
audioSessionAnnouncer = null
player?.release()
player = null
playerFactory.clearPreferences()
trackSelector = null
appContext = null
cacheManager?.release()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

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

/**
Expand All @@ -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
Expand All @@ -36,59 +39,74 @@ 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(
minMs = minMs,
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(
minMs = buffers.minMs,
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,
)
}

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