Skip to content

fix(cast): various DLNA fixes - #1416

Open
baptleduc wants to merge 8 commits into
Flow-Tube:mainfrom
baptleduc:fix/dlna-cast-audio
Open

baptleduc wants to merge 8 commits into
Flow-Tube:mainfrom
baptleduc:fix/dlna-cast-audio

Conversation

@baptleduc

@baptleduc baptleduc commented Oct 22, 2026 •

Copy link
Copy Markdown

Summary

DLNA casts played without sound, started slowly and stayed in low quality. One commit per fix:

  • Sound: the cast got the player's video-only stream. It now uses the player's resolved streams, with the unprocessed audio list (the processed one is usually Opus only).
  • Busy TV: renderers holding a previous session reject the new video with UPnP 701. The cast now sends Stop and waits for the TV to stop.
  • Quality: renderers start on the first variant, which was the lowest. Only the best one is sent.
  • Start time: each playlist was one whole-file segment. It now has one byte-range segment per entry of the stream's segment index, so the TV starts in seconds.
  • Fallbacks: the cast tries segmented AV1, segmented H.264, whole-file HLS, then a pre-muxed stream. Failed steps are remembered per TV until the app exits.
  • AV1: its own first step, not a second variant, since the tested LG fails playlists that mix codecs.
  • Unit tests for the segmented playlist and the segment ranges, and the nightly notes.

I'm not used to Kotlin or mobile apps, so I wrote this with an AI assistant (Claude), as the Assisted-by: trailers say. I tested each step on a real TV. Please review it like any other change; I'll answer questions or rework what doesn't fit.

Related issue

None.

Change type

  • Bug fix
  • Feature
  • Refactor or maintenance
  • Build, packaging, or CI
  • Documentation

Validation

  • ./gradlew :app:assembleGithubDebug
  • ./gradlew :app:testGithubDebugUnitTest (4,368 tests, 0 failures)
  • I ran any additional flavor-specific build or test tasks affected by this change. (./gradlew ktlintCheck, python3 .github/scripts/nightly_notes.py --check)
  • I manually tested the affected behavior on an Android device or emulator.

Test device and Android version: Google Pixel 10 Pro, Android 17, casting to an LG OLED55C5 (webOS).

The video plays with sound in 4K, and the TV requests the first segment about 2 s after the cast starts.

Risk and compatibility

  • DLNA casting only. No preference, database or permission change.

  • A TV that can't play a step takes up to about 20 s to fall back, once per app run.

  • Only tested on one TV.

  • Known issue, not fixed here: with Flow in the background, Android 17 cuts the app's network and the cast stalls. A foreground service while casting would fix it; I can do that in a follow-up.

  • The change does not introduce secrets, private data, or unexpected telemetry.

  • No new user-facing text.

  • No dependency changes.

  • No Room schema change.

  • No breaking changes.

🤖 Generated with Claude Code

@baptleduc
baptleduc requested a review from A-EDev as a code owner October 22, 2026 22:17
@github-actions github-actions Bot added documentation Improvements or additions to documentation player Video and audio playback ui Compose UI, theming, or Material 3 surfaces labels Oct 22, 2026
@baptleduc
baptleduc force-pushed the fix/dlna-cast-audio branch from 6e63bc7 to a2cf508 Compare October 22, 2026 22:25
@baptleduc baptleduc changed the title fix(cast): DLNA casts play with sound, start in seconds and fall back on unsupported formats fix(cast): DLNA sound, fast start and fallbacks Oct 22, 2026
@baptleduc baptleduc changed the title fix(cast): DLNA sound, fast start and fallbacks fix(cast): various DLNA fixes Oct 22, 2026
@baptleduc
baptleduc force-pushed the fix/dlna-cast-audio branch 2 times, most recently from c1537da to 7b46e7c Compare October 22, 2026 22:52
Casting to a DLNA TV plays the picture without sound.

The cast sheet passes a null StreamInfo to castStreamInfo(), so the
video + AAC audio HLS path never runs and the renderer gets the
player's current URI, a video-only stream.

Pass the streams the player already resolved instead, and the
player's duration, in place of the StreamInfo. The audio list must be
the unprocessed one: processAudioStreams() keeps one stream per track,
usually Opus, and casting only accepts AAC. The pre-muxed streams are
passed too, so the fallback without AAC still has sound.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Baptiste Le Duc <baptiste.leduc@etik.com>
Some renderers reject SetAVTransportURI with UPnP error 701 while they
hold a previous session, e.g. after the app restarted mid-cast. Casting
then fails until the TV is restarted.

Send Stop to the target renderer right before SetAVTransportURI,
ignoring its failure when the renderer is idle. Some renderers only
finish stopping after answering, so wait up to five seconds for
GetTransportInfo to report STOPPED or NO_MEDIA_PRESENT. The Stop request
moves to stop(device), next to play(device), and stop() uses it too.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Baptiste Le Duc <baptiste.leduc@etik.com>
Casts start slowly and stay in the lowest quality.

The master playlist lists variants from the lowest resolution up and
renderers start on the first one. Each media playlist is a single
whole-file segment, so they can never switch. Registering each variant
also costs a blocking HEAD request.

Pass only the best variant, and keep it H.264: filtering on mp4 lets
AV1 through, which is all YouTube offers above 1080p and most
renderers cannot decode.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Baptiste Le Duc <baptiste.leduc@etik.com>
A cast only starts once the renderer has downloaded the whole video,
since each HLS media playlist is a single whole-file segment.

YouTube's adaptive streams are fragmented MP4 with a segment index
(sidx), and the ItagItem carries the byte ranges of the init segment
and of that index. Load the index with
Media3's DashUtil.loadChunkIndex() and emit one EXT-X-BYTERANGE
segment per entry, with EXT-X-MAP for the init segment. The proxy
already forwards Range requests.

Without ranges or a loadable index, the single-segment playlist stays.

The playlist text and the range checks are covered by unit tests.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Baptiste Le Duc <baptiste.leduc@etik.com>
…in startCast()

castTo() takes every HLS and pre-muxed parameter, then chooses between
them again after castStreamInfo() already did.

Register the chosen stream in castStreamInfo()'s coroutine and pass
its URL to a suspending startCast() that only drives the renderer.
This prepares trying several formats in turn.

No functional changes.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Baptiste Le Duc <baptiste.leduc@etik.com>
Renderers do not say which HLS formats they play, so one that cannot
play segmented fMP4 fails the cast with no fallback.

Try these modes in order:

  SEGMENTED   segmented fMP4 HLS, starts in seconds
  WHOLE_FILE  single-segment HLS, downloads the whole file first
  MUXED       best pre-muxed stream, else the current player URL

A mode fails when SetAVTransportURI is rejected or, for SEGMENTED,
when the renderer is not PLAYING within 10 seconds. Failed modes are
remembered per renderer until the app exits, and skipped. Modes a
video cannot provide, or whose streams fail to register, are skipped
without being held against the renderer.

SEGMENTED requires both video and audio segment indexes and fails
rather than degrading to one segment, which would miss the timeout and
blame the renderer.

A new cast or stop() cancels a fallback in progress. A failing Play no
longer fails the cast: renderers that buffer before answering time it
out, then play.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Baptiste Le Duc <baptiste.leduc@etik.com>
Renderers that decode AV1 never get more than 1080p, as only H.264 is
cast.

Add a SEGMENTED_AV1 mode ahead of SEGMENTED, used when an AV1 stream
goes above the best H.264 one. It is a separate mode rather than a
second variant because renderers can fail playlists that mix codecs:
an LG OLED55C5 plays H.264 or AV1 alone but stays in TRANSITIONING
when both are listed. A renderer that cannot play AV1 misses the start
timeout once, falls back to H.264, and skips AV1 from then on.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Baptiste Le Duc <baptiste.leduc@etik.com>
Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Baptiste Le Duc <baptiste.leduc@etik.com>
@baptleduc
baptleduc force-pushed the fix/dlna-cast-audio branch from 7b46e7c to 292cf6c Compare October 22, 2026 23:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation player Video and audio playback ui Compose UI, theming, or Material 3 surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant