fix(player): do not start a video before audio focus is granted (DR-145)

Video manages audio focus by hand (handleAudioFocus=false, since
ExoPlayer's automatic handling is reserved for the audio path), and all
three outcomes of the request were treated as success. AUDIOFOCUS_
REQUEST_DELAYED — which setAcceptsDelayedFocusGain(true) explicitly
invites, and which means the system is withholding our audio until it
calls back — and an outright REQUEST_FAILED were logged and then followed
by playWhenReady = true. The picture rolled with no sound, which to the
user is indistinguishable from a broken stream.

Hold playback when focus is not granted and start it from the
AUDIOFOCUS_GAIN callback. An explicit play() re-requests focus instead of
resuming into a stream the system is still muting, guarded by a
held-focus flag so repeated plays do not leak focus requests. LOSS clears
the pending flag so an unrelated later GAIN cannot start playback the
user never asked for.

Verified by compiling :app:compileArm64DebugKotlin. Not unit-tested: this
tree has no Kotlin test source set (the Gradle project lives in the
generated, gitignored gen/ tree), so the logic cannot be exercised off
device without restructuring the Android build.
This commit is contained in:
2026-08-09 15:06:49 +02:00
parent cc7f1cece0
commit 19bc265a8d
2 changed files with 79 additions and 10 deletions
+1
View File
@@ -303,6 +303,7 @@ Internal architecture, components, and application logic.
| DR-141 | One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under `/library/**` and merely *navigated* to `/search` (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from `?q=` against `library.searchQuery` on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on `/search` too (`showHeaderSearch`) and is the sole md+ input — the page's own input is `md:hidden` — and on that route it republishes the query into the URL with `replaceState`, so a whole session of typing costs one history entry. The page *consumes* that URL once per distinct value (`seedFromSearchUrl` against a non-reactive `applied` marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on `/search` with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted route | UI | UR-049, UR-054 | Done |
| DR-133 | A downloaded file has exactly one on-disk path, and the row that names it is authoritative. `downloads.file_path` starts relative to the storage root, but the worker rewrites it to the absolute path it actually wrote when the transfer completes — so a *completed* row is already rooted. The video player's offline branch rooted it a second time, handing the asset protocol `/data/user/0/app//data/user/0/app/videos/x.mp4`; the webview reported `MEDIA_ERR_SRC_NOT_SUPPORTED` with `NETWORK_NO_SOURCE`, so every downloaded video failed to play while audio — which resolves the same column through Rust's `resolve_local_media_path`, without re-rooting — played fine. The join is absolute-aware (POSIX, Windows drive letters and UNC) so rows written before completion still resolve | Playback | UR-071 | Done |
| DR-134 | The webview can actually fetch the local files it is handed. `convertFileSrc` rewrites a path to `http://asset.localhost/…` unconditionally, but Tauri only answers that origin when the `protocol-asset` cargo feature is compiled in *and* `app.security.assetProtocol.enable` is set — neither was, so every such URL reached a protocol with no handler and the webview reported `NETWORK_NO_SOURCE`. This silently defeated both offline video (`<video src>`) and the cached-thumbnail path in `imageCache`, which fails soft to the server copy and so hid the breakage whenever the server was reachable. The scope is `$APPDATA/**` — the storage root under which the database, `downloads/` and the thumbnail cache all live — rather than an unrestricted grant, so the webview can read the app's own media and nothing else | Security | UR-071 | Done |
| DR-145 | Video playback starts only once the app actually holds audio focus. Video manages focus by hand (`handleAudioFocus=false`, because ExoPlayer's automatic handling is reserved for the audio path), and the request's three outcomes were all treated as success: `AUDIOFOCUS_REQUEST_DELAYED` — which `setAcceptsDelayedFocusGain(true)` explicitly invites, and which means the system is *withholding our audio* until it calls back — and an outright `REQUEST_FAILED` were logged and then followed by `playWhenReady = true`. The picture rolled with no sound, indistinguishable to the user from a broken stream. Playback is now held when focus is not granted and started from the `AUDIOFOCUS_GAIN` callback; an explicit `play()` re-requests focus rather than resuming into a stream the system is still muting, guarded by a held-focus flag so repeated plays do not leak focus requests. A `LOSS` clears the pending flag, so an unrelated later `GAIN` cannot start playback the user never asked for | Playback | UR-004 | Done |
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
---
@@ -240,6 +240,20 @@ class JellyTauPlayer(private val appContext: Context) {
}
private var audioFocusRequest: AudioFocusRequest? = null
/**
* Set when a video was loaded but audio focus was not granted outright.
*
* `setAcceptsDelayedFocusGain(true)` means the system may answer DELAYED and
* hand us focus later; until then it withholds our audio. Starting playback
* anyway plays the video silently, which is exactly the "video has no sound"
* symptom. We hold playback and start it from the AUDIOFOCUS_GAIN callback.
*/
private var pendingPlayOnFocusGain = false
/** Whether we currently hold audio focus, so `play()` does not re-request
* (and leak) a focus request we already own. */
private var hasAudioFocus = false
init {
// Configure audio attributes for music playback with audio focus handling
val audioAttributes = AudioAttributes.Builder()
@@ -431,6 +445,15 @@ class JellyTauPlayer(private val appContext: Context) {
*/
fun play() {
mainHandler.post {
// Video manages focus by hand, so an explicit play after a refusal (or
// after a LOSS paused us) has to ask again — otherwise it resumes into
// a stream the system is still muting.
if (currentMediaType == MediaType.VIDEO && !hasAudioFocus && !requestAudioFocus()) {
pendingPlayOnFocusGain = true
android.util.Log.d("JellyTauPlayer", "play() without audio focus - holding until GAIN")
return@post
}
pendingPlayOnFocusGain = false
exoPlayer.play()
}
}
@@ -819,14 +842,17 @@ class JellyTauPlayer(private val appContext: Context) {
android.util.Log.d("JellyTauPlayer", "ExoPlayer audio session ID: ${exoPlayer.audioSessionId}")
// Setup video surface if needed
var focusGranted = true
if (currentMediaType == MediaType.VIDEO) {
getOrCreateSurfaceView()
android.util.Log.d("JellyTauPlayer", "Video surface created for playback")
// Automatically attach the surface to the Activity
autoAttachSurface()
// CRITICAL: Request audio focus for video playback
requestAudioFocus()
// CRITICAL: Request audio focus for video playback. Video manages
// focus by hand (handleAudioFocus=false above), so nothing else
// will hold playback back if the request is delayed or refused.
focusGranted = requestAudioFocus()
} else {
clearVideoSurface()
// Abandon audio focus when switching to audio (audio uses ExoPlayer's built-in handling)
@@ -896,8 +922,13 @@ class JellyTauPlayer(private val appContext: Context) {
}
android.util.Log.d("JellyTauPlayer", "✓ Current volume: ${exoPlayer.volume}, deviceVolume: ${audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)}/${audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}")
exoPlayer.playWhenReady = true
android.util.Log.d("JellyTauPlayer", "playWhenReady set to TRUE. Current state: ${exoPlayer.playbackState}")
// Only roll if we hold audio focus. A DELAYED grant means the system
// is withholding our audio until it calls back with AUDIOFOCUS_GAIN;
// playing through it produces picture with no sound. Playback resumes
// from the focus listener instead.
pendingPlayOnFocusGain = !focusGranted
exoPlayer.playWhenReady = focusGranted
android.util.Log.d("JellyTauPlayer", "playWhenReady set to $focusGranted (pendingPlayOnFocusGain=$pendingPlayOnFocusGain). Current state: ${exoPlayer.playbackState}")
// Start the foreground service for lockscreen controls
startPlaybackService()
@@ -1154,8 +1185,14 @@ class JellyTauPlayer(private val appContext: Context) {
/**
* Request audio focus for video playback.
* This is critical for video to have audio on Android.
*
* TRACES: UR-004 | DR-145
*
* @return true if focus was granted outright and playback may start now.
* false for a DELAYED or refused request — the caller must hold playback
* and let the AUDIOFOCUS_GAIN callback start it, or the video plays mute.
*/
private fun requestAudioFocus() {
private fun requestAudioFocus(): Boolean {
android.util.Log.d("JellyTauPlayer", "Requesting audio focus for video playback")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -1172,17 +1209,29 @@ class JellyTauPlayer(private val appContext: Context) {
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GAINED - ensuring full volume")
hasAudioFocus = true
if (exoPlayer.volume < 1.0f) {
exoPlayer.volume = 1.0f
android.util.Log.d("JellyTauPlayer", " Volume restored to 1.0 from ${exoPlayer.volume}")
}
// A delayed grant arriving: this is the point at which
// the video may actually be heard, so start it now.
if (pendingPlayOnFocusGain) {
pendingPlayOnFocusGain = false
android.util.Log.d("JellyTauPlayer", " Delayed focus granted - starting held playback")
exoPlayer.playWhenReady = true
}
}
AudioManager.AUDIOFOCUS_LOSS -> {
android.util.Log.d("JellyTauPlayer", "Audio focus LOST - pausing")
hasAudioFocus = false
pendingPlayOnFocusGain = false
pause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
android.util.Log.d("JellyTauPlayer", "Audio focus LOST TRANSIENT - pausing")
hasAudioFocus = false
pendingPlayOnFocusGain = false
pause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
@@ -1193,16 +1242,25 @@ class JellyTauPlayer(private val appContext: Context) {
}
.build()
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
when (result) {
return when (val result = audioManager.requestAudioFocus(audioFocusRequest!!)) {
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> {
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GRANTED")
hasAudioFocus = true
true
}
AudioManager.AUDIOFOCUS_REQUEST_FAILED -> {
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED!")
// Something holds exclusive focus (a call, say). Playing now
// would be a silent video, so hold and wait for the grant.
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED - holding playback")
false
}
AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> {
android.util.Log.d("JellyTauPlayer", "⏳ Audio focus DELAYED")
android.util.Log.d("JellyTauPlayer", "⏳ Audio focus DELAYED - holding playback until GAIN")
false
}
else -> {
android.util.Log.w("JellyTauPlayer", "Unknown audio focus result: $result - holding playback")
false
}
}
} else {
@@ -1214,10 +1272,15 @@ class JellyTauPlayer(private val appContext: Context) {
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
return if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GRANTED (legacy)")
hasAudioFocus = true
true
} else {
// Pre-O has no delayed grant and no listener to resume from, so a
// refusal is terminal for this attempt; the user can hit play again.
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED (legacy)!")
false
}
}
}
@@ -1228,6 +1291,11 @@ class JellyTauPlayer(private val appContext: Context) {
private fun abandonAudioFocus() {
android.util.Log.d("JellyTauPlayer", "Abandoning audio focus")
// No focus, nothing to resume: a stale flag would start playback the next
// time some unrelated GAIN arrives.
pendingPlayOnFocusGain = false
hasAudioFocus = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let {
val result = audioManager.abandonAudioFocusRequest(it)