From 978ec551acf2d1c0a486c7b081be1e4e06809534 Mon Sep 17 00:00:00 2001 From: Baptiste Le Duc Date: Fri, 23 Oct 2026 00:40:51 +0200 Subject: [PATCH 1/8] fix(cast): send the audio track when casting to a DLNA renderer 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 --- .../flow/player/EnhancedPlayerManager.kt | 9 ++ .../aedev/flow/player/dlna/DlnaCastManager.kt | 124 +++++++++--------- .../player/dialogs/PlayerDialogsContainer.kt | 10 +- 3 files changed, 76 insertions(+), 67 deletions(-) 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..bb734bb9ed 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 @@ -155,6 +155,9 @@ class EnhancedPlayerManager private constructor() { private var currentVideoId: String? = null private var availableVideoStreams: List = emptyList() private var availableAudioStreams: List = emptyList() + + // processAudioStreams keeps one stream per track, usually Opus; DLNA casting needs the AAC ones. + private var castAudioStreams: List = emptyList() private val subtitleTracks = SubtitleTracks() private val subtitleDelay = SubtitleDelay() private var subtitleDelayVideoId: String? = null @@ -1187,6 +1190,7 @@ class EnhancedPlayerManager private constructor() { // Process streams using StreamProcessor availableVideoStreams = StreamProcessor.processVideoStreams(videoStreams) availableAudioStreams = StreamProcessor.processAudioStreams(audioStreams) + castAudioStreams = audioStreams subtitleTracks.load(videoId, StreamProcessor.processCaptions(subtitles), acceptsEmbedded = localFilePath != null) if (audioStream == null && availableAudioStreams.isEmpty()) { Log.w(TAG, "setStreams: no separate audio stream for $videoId; attempting video-only/muxed playback") @@ -2297,6 +2301,7 @@ class EnhancedPlayerManager private constructor() { availableVideoStreams = StreamProcessor.processVideoStreams(data.videoStreams) availableAudioStreams = StreamProcessor.processAudioStreams(data.audioStreams) + castAudioStreams = data.audioStreams subtitleTracks.load(data.enrichedVideo.id, StreamProcessor.processCaptions(data.subtitles), acceptsEmbedded = false) resetSubtitleDelayFor(data.enrichedVideo.id) val ladder = AdaptiveLadder.forPreload(data.videoStream, availableVideoStreams, data.preferredCodec) @@ -2623,6 +2628,10 @@ class EnhancedPlayerManager private constructor() { fun getPlayer(): ExoPlayer? = player + fun getCastVideoStreams(): List = availableVideoStreams + + fun getCastAudioStreams(): List = castAudioStreams + fun getCurrentPosition(): Long = player?.currentPosition ?: 0L fun getDuration(): Long = player?.duration ?: 0L diff --git a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt index 2027bc51bc..52404706d3 100644 --- a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt @@ -19,7 +19,8 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.AudioStream +import org.schabi.newpipe.extractor.stream.VideoStream import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserFactory import java.net.DatagramPacket @@ -284,7 +285,7 @@ object DlnaCastManager { * The proxy runs on the phone and relays the stream, so the renderer * connects to the phone instead of YouTube. * - * Build cast variants from an extracted [StreamInfo] and start casting to [device]. + * Build cast variants from the player's streams and start casting to [device]. * Prefers HLS-style separate video variants + best AAC audio; falls back to a pre-muxed * stream (or [currentPlayerUrl]) when separate streams aren't usable. * @@ -294,71 +295,66 @@ object DlnaCastManager { fun castStreamInfo( device: DlnaDevice, title: String, - streamInfo: StreamInfo?, currentPlayerUrl: String?, + videoOnlyStreams: List, + audioStreams: List, + muxedStreams: List, + durationSeconds: Long, ) { - if (streamInfo != null) { - val duration = streamInfo.duration - - val videoVariants = - (streamInfo.videoOnlyStreams ?: emptyList()) - .filter { it.height > 0 } - .filter { - val mime = it.format?.mimeType ?: "" - mime.contains("mp4") || mime.contains("avc") - }.sortedByDescending { VideoCodecUtils.qualityHeightFromStream(it) } - .map { stream -> - CastStreamVariant( - url = stream.content ?: stream.url ?: "", - width = stream.width.takeIf { it > 0 } ?: (stream.height * 16 / 9), - height = stream.height, - bitrate = stream.bitrate.takeIf { it > 0 } ?: 2_500_000, - mime = "video/mp4", - codec = stream.codec?.takeIf { it.isNotBlank() } ?: "avc1.64001F", - ) - }.filter { it.url.isNotEmpty() } - - val bestAudio = - streamInfo.audioStreams - ?.filter { - val mime = it.format?.mimeType ?: "" - mime.contains("mp4") || mime.contains("m4a") || mime.contains("aac") - }?.maxByOrNull { it.bitrate } - - val audioUrl = bestAudio?.let { it.content ?: it.url } - val audioBitrate = bestAudio?.bitrate?.takeIf { it > 0 } ?: 128_000 - val audioCodec = bestAudio?.codec?.takeIf { it?.isNotBlank() == true } ?: "mp4a.40.2" - val audioMime = - bestAudio?.format?.mimeType?.let { - if (it.contains("mp4") || it.contains("m4a")) "audio/mp4" else it - } ?: "audio/mp4" - - if (videoVariants.isNotEmpty() && audioUrl != null) { - Log.d(TAG, "HLS cast: ${videoVariants.size} variants, audio=${audioBitrate / 1000}kbps") - castTo( - device = device, - title = title, - videoVariants = videoVariants, - audioUrl = audioUrl, - audioMime = audioMime, - audioBitrate = audioBitrate, - audioCodec = audioCodec, - durationSeconds = duration, - ) - } else { - val bestMuxed = - streamInfo.videoStreams - ?.filter { it.height > 0 } - ?.maxByOrNull { VideoCodecUtils.qualityHeightFromStream(it) } - val muxedUrl = bestMuxed?.let { it.content ?: it.url } ?: currentPlayerUrl - if (muxedUrl != null && muxedUrl.isNotEmpty() && !muxedUrl.startsWith("local://")) { - Log.d(TAG, "Fallback to pre-muxed: ${bestMuxed?.let(VideoCodecUtils::qualityHeightFromStream)}p") - castTo(device = device, title = title, fallbackVideoUrl = muxedUrl) - } - } + val videoVariants = + videoOnlyStreams + .filter { it.height > 0 } + .filter { + val mime = it.format?.mimeType ?: "" + mime.contains("mp4") || mime.contains("avc") + }.sortedByDescending { VideoCodecUtils.qualityHeightFromStream(it) } + .map { stream -> + CastStreamVariant( + url = stream.content ?: stream.url ?: "", + width = stream.width.takeIf { it > 0 } ?: (stream.height * 16 / 9), + height = stream.height, + bitrate = stream.bitrate.takeIf { it > 0 } ?: 2_500_000, + mime = "video/mp4", + codec = stream.codec?.takeIf { it.isNotBlank() } ?: "avc1.64001F", + ) + }.filter { it.url.isNotEmpty() } + + val bestAudio = + audioStreams + .filter { + val mime = it.format?.mimeType ?: "" + mime.contains("mp4") || mime.contains("m4a") || mime.contains("aac") + }.maxByOrNull { it.bitrate } + + val audioUrl = bestAudio?.let { it.content ?: it.url } + val audioBitrate = bestAudio?.bitrate?.takeIf { it > 0 } ?: 128_000 + val audioCodec = bestAudio?.codec?.takeIf { it?.isNotBlank() == true } ?: "mp4a.40.2" + val audioMime = + bestAudio?.format?.mimeType?.let { + if (it.contains("mp4") || it.contains("m4a")) "audio/mp4" else it + } ?: "audio/mp4" + + if (videoVariants.isNotEmpty() && audioUrl != null) { + Log.d(TAG, "HLS cast: ${videoVariants.size} variants, audio=${audioBitrate / 1000}kbps") + castTo( + device = device, + title = title, + videoVariants = videoVariants, + audioUrl = audioUrl, + audioMime = audioMime, + audioBitrate = audioBitrate, + audioCodec = audioCodec, + durationSeconds = durationSeconds, + ) } else { - if (currentPlayerUrl != null && currentPlayerUrl.isNotEmpty() && !currentPlayerUrl.startsWith("local://")) { - castTo(device = device, title = title, fallbackVideoUrl = currentPlayerUrl) + val bestMuxed = + muxedStreams + .filter { it.height > 0 } + .maxByOrNull { VideoCodecUtils.qualityHeightFromStream(it) } + val muxedUrl = bestMuxed?.let { it.content ?: it.url } ?: currentPlayerUrl + if (muxedUrl != null && muxedUrl.isNotEmpty() && !muxedUrl.startsWith("local://")) { + Log.d(TAG, "Fallback to pre-muxed: ${bestMuxed?.let(VideoCodecUtils::qualityHeightFromStream)}p") + castTo(device = device, title = title, fallbackVideoUrl = muxedUrl) } } } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/dialogs/PlayerDialogsContainer.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/dialogs/PlayerDialogsContainer.kt index 2d9e983816..8b85b1ce6f 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/player/dialogs/PlayerDialogsContainer.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/dialogs/PlayerDialogsContainer.kt @@ -98,19 +98,23 @@ internal fun PlayerDialogsContainer( isCasting = DlnaCastManager.isCasting, videoTitle = video.title, onDeviceSelected = { device -> + val playerManager = EnhancedPlayerManager.getInstance() val currentPlayerUrl = - EnhancedPlayerManager - .getInstance() + playerManager .getPlayer() ?.currentMediaItem ?.localConfiguration ?.uri ?.toString() + val (videoOnlyStreams, muxedStreams) = playerManager.getCastVideoStreams().partition { it.isVideoOnly } DlnaCastManager.castStreamInfo( device = device, title = video.title, - streamInfo = null, currentPlayerUrl = currentPlayerUrl, + videoOnlyStreams = videoOnlyStreams, + audioStreams = playerManager.getCastAudioStreams(), + muxedStreams = muxedStreams, + durationSeconds = playerManager.getDuration() / 1000, ) screenState.closeSheet() }, From 7645199b840c2e0504d159480efe291d200140ae Mon Sep 17 00:00:00 2001 From: Baptiste Le Duc Date: Fri, 23 Oct 2026 00:41:31 +0200 Subject: [PATCH 2/8] fix(cast): stop the renderer's previous session before loading a video 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 --- .../aedev/flow/player/dlna/DlnaCastManager.kt | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt index 52404706d3..addaa43036 100644 --- a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -49,6 +50,7 @@ object DlnaCastManager { private const val SSDP_ADDR = "239.255.255.250" private const val SSDP_PORT = 1900 private const val DISCOVERY_TIMEOUT_MS = 5_000L + private const val STOP_TIMEOUT_MS = 5_000L private val SOAP_TYPE = "text/xml; charset=\"utf-8\"".toMediaType() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -401,6 +403,12 @@ object DlnaCastManager { Log.i(TAG, "Casting single stream via proxy: $castUrl") } + // Renderers still holding a previous session reject SetAVTransportURI with UPnP 701. + runCatching { stop(device) } + // Some only finish stopping after answering, and still reject the URI until then. + withTimeoutOrNull(STOP_TIMEOUT_MS) { + while (transportState(device).let { it != null && it != "STOPPED" && it != "NO_MEDIA_PRESENT" }) delay(500) + } setAVTransportUri(device, castUrl, title, contentType) delay(500) play(device) @@ -413,19 +421,22 @@ object DlnaCastManager { } } + private fun transportState(device: DlnaDevice): String? = + runCatching { + val response = + sendSoapWithResponse( + device.avTransportUrl, + "urn:schemas-upnp-org:service:AVTransport:1#GetTransportInfo", + soapAction("GetTransportInfo", "urn:schemas-upnp-org:service:AVTransport:1", "0"), + ) + Regex("([^<]+)").find(response)?.groupValues?.get(1) + }.getOrNull() + fun stop() { val device = _currentDevice.value ?: return scope.launch { try { - sendSoap( - device.avTransportUrl, - "urn:schemas-upnp-org:service:AVTransport:1#Stop", - soapAction( - "Stop", - "urn:schemas-upnp-org:service:AVTransport:1", - "0", - ), - ) + stop(device) } catch (e: Exception) { Log.e(TAG, "stop failed: ${e.message}") } finally { @@ -586,6 +597,14 @@ object DlnaCastManager { ) } + private fun stop(device: DlnaDevice) { + sendSoap( + device.avTransportUrl, + "urn:schemas-upnp-org:service:AVTransport:1#Stop", + soapAction("Stop", "urn:schemas-upnp-org:service:AVTransport:1", "0"), + ) + } + private fun play(device: DlnaDevice) { val body = soapAction( From 2edf39865989d79c444356b968cb7dfc65386c65 Mon Sep 17 00:00:00 2001 From: Baptiste Le Duc Date: Fri, 23 Oct 2026 00:41:48 +0200 Subject: [PATCH 3/8] fix(cast): offer only the best video variant to DLNA renderers 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 --- .../aedev/flow/player/dlna/DlnaCastManager.kt | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt index addaa43036..87e1a91e53 100644 --- a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt @@ -304,22 +304,23 @@ object DlnaCastManager { durationSeconds: Long, ) { val videoVariants = - videoOnlyStreams - .filter { it.height > 0 } - .filter { - val mime = it.format?.mimeType ?: "" - mime.contains("mp4") || mime.contains("avc") - }.sortedByDescending { VideoCodecUtils.qualityHeightFromStream(it) } - .map { stream -> - CastStreamVariant( - url = stream.content ?: stream.url ?: "", - width = stream.width.takeIf { it > 0 } ?: (stream.height * 16 / 9), - height = stream.height, - bitrate = stream.bitrate.takeIf { it > 0 } ?: 2_500_000, - mime = "video/mp4", - codec = stream.codec?.takeIf { it.isNotBlank() } ?: "avc1.64001F", - ) - }.filter { it.url.isNotEmpty() } + listOfNotNull( + videoOnlyStreams + .filter { it.height > 0 && !(it.content ?: it.url).isNullOrEmpty() } + // Only the best variant is sent, and above 1080p it is AV1, which most renderers can't decode. + .filter { VideoCodecUtils.codecKeyFromStream(it) == "h264" } + // Renderers start on the first variant listed, and each one is probed before they get the playlist. + .maxByOrNull { VideoCodecUtils.qualityHeightFromStream(it) }, + ).map { stream -> + CastStreamVariant( + url = stream.content ?: stream.url ?: "", + width = stream.width.takeIf { it > 0 } ?: (stream.height * 16 / 9), + height = stream.height, + bitrate = stream.bitrate.takeIf { it > 0 } ?: 2_500_000, + mime = "video/mp4", + codec = stream.codec?.takeIf { it.isNotBlank() } ?: "avc1.64001F", + ) + } val bestAudio = audioStreams From 26b40368d14feb5d7316217e84f59711434321eb Mon Sep 17 00:00:00 2001 From: Baptiste Le Duc Date: Fri, 23 Oct 2026 00:42:37 +0200 Subject: [PATCH 4/8] feat(cast): split HLS casts into byte-range segments 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 --- .../flow/player/dlna/CastStreamVariant.kt | 24 +++++- .../aedev/flow/player/dlna/DlnaCastManager.kt | 4 + .../flow/player/dlna/StreamProxyServer.kt | 83 ++++++++++++++++++- .../flow/player/dlna/CastStreamVariantTest.kt | 47 +++++++++++ .../flow/player/dlna/StreamProxyServerTest.kt | 35 ++++++++ 5 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/io/github/aedev/flow/player/dlna/CastStreamVariantTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/player/dlna/CastStreamVariant.kt b/app/src/main/java/io/github/aedev/flow/player/dlna/CastStreamVariant.kt index df03de9084..53f23a74f9 100644 --- a/app/src/main/java/io/github/aedev/flow/player/dlna/CastStreamVariant.kt +++ b/app/src/main/java/io/github/aedev/flow/player/dlna/CastStreamVariant.kt @@ -1,10 +1,32 @@ package io.github.aedev.flow.player.dlna +import org.schabi.newpipe.extractor.stream.Stream + data class CastStreamVariant( val url: String, val width: Int, val height: Int, val bitrate: Int, val mime: String = "video/mp4", - val codec: String = "avc1.64001F" + val codec: String = "avc1.64001F", + val segmentRanges: CastSegmentRanges? = null, +) + +/** Inclusive byte ranges of a progressive stream's init segment and its segment index (sidx). */ +data class CastSegmentRanges( + val init: LongRange, + val index: LongRange, ) + +/** Byte count of an inclusive range; [LongRange.count] would walk every byte. */ +internal val LongRange.length: Long get() = last - first + 1 + +internal fun Stream.castSegmentRanges(): CastSegmentRanges? { + val itag = itagItem ?: return null + if (itag.initStart < 0 || itag.initEnd <= itag.initStart) return null + if (itag.indexStart < 0 || itag.indexEnd <= itag.indexStart) return null + return CastSegmentRanges( + init = itag.initStart.toLong()..itag.initEnd.toLong(), + index = itag.indexStart.toLong()..itag.indexEnd.toLong(), + ) +} diff --git a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt index 87e1a91e53..778281bd2c 100644 --- a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt @@ -319,6 +319,7 @@ object DlnaCastManager { bitrate = stream.bitrate.takeIf { it > 0 } ?: 2_500_000, mime = "video/mp4", codec = stream.codec?.takeIf { it.isNotBlank() } ?: "avc1.64001F", + segmentRanges = stream.castSegmentRanges(), ) } @@ -347,6 +348,7 @@ object DlnaCastManager { audioMime = audioMime, audioBitrate = audioBitrate, audioCodec = audioCodec, + audioSegmentRanges = bestAudio?.castSegmentRanges(), durationSeconds = durationSeconds, ) } else { @@ -370,6 +372,7 @@ object DlnaCastManager { audioMime: String = "audio/mp4", audioBitrate: Int = 128_000, audioCodec: String = "mp4a.40.2", + audioSegmentRanges: CastSegmentRanges? = null, durationSeconds: Long = 0, fallbackVideoUrl: String? = null, ) { @@ -386,6 +389,7 @@ object DlnaCastManager { audioMime = audioMime, audioBitrate = audioBitrate, audioCodec = audioCodec, + audioSegmentRanges = audioSegmentRanges, durationSeconds = durationSeconds, ) contentType = "application/vnd.apple.mpegurl" diff --git a/app/src/main/java/io/github/aedev/flow/player/dlna/StreamProxyServer.kt b/app/src/main/java/io/github/aedev/flow/player/dlna/StreamProxyServer.kt index 7b064651ef..57ecd8a96e 100644 --- a/app/src/main/java/io/github/aedev/flow/player/dlna/StreamProxyServer.kt +++ b/app/src/main/java/io/github/aedev/flow/player/dlna/StreamProxyServer.kt @@ -3,6 +3,17 @@ package io.github.aedev.flow.player.dlna import android.content.Context import android.net.wifi.WifiManager import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.dash.DashUtil +import androidx.media3.exoplayer.dash.manifest.BaseUrl +import androidx.media3.exoplayer.dash.manifest.RangedUri +import androidx.media3.exoplayer.dash.manifest.Representation +import androidx.media3.exoplayer.dash.manifest.SegmentBase +import androidx.media3.extractor.ChunkIndex import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -15,9 +26,11 @@ import java.net.InetAddress import java.net.ServerSocket import java.net.Socket import java.net.SocketException +import java.util.Locale import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.ceil /** * Local HTTP proxy server that relays YouTube streams to DLNA renderers. @@ -63,6 +76,30 @@ class StreamProxyServer private constructor() { else -> "mp4" } + /** fMP4 HLS media playlist: the init segment at [init], then one byte-range segment per [index] entry. */ + @OptIn(UnstableApi::class) + internal fun segmentedMediaPlaylist( + proxyStreamUrl: String, + init: LongRange, + index: ChunkIndex, + ): String { + val durations = index.durationsUs.map { it / 1_000_000.0 } + return buildString { + appendLine("#EXTM3U") + // EXT-X-MAP outside I-frame playlists requires protocol version 6. + appendLine("#EXT-X-VERSION:6") + appendLine("#EXT-X-TARGETDURATION:${ceil(durations.max()).toLong()}") + appendLine("#EXT-X-PLAYLIST-TYPE:VOD") + appendLine("#EXT-X-MAP:URI=\"$proxyStreamUrl\",BYTERANGE=\"${init.length}@${init.first}\"") + for (i in 0 until index.length) { + appendLine("#EXTINF:${"%.3f".format(Locale.ROOT, durations[i])},") + appendLine("#EXT-X-BYTERANGE:${index.sizes[i]}@${index.offsets[i]}") + appendLine(proxyStreamUrl) + } + appendLine("#EXT-X-ENDLIST") + } + } + @Volatile private var instance: StreamProxyServer? = null @@ -208,6 +245,7 @@ class StreamProxyServer private constructor() { audioMime: String = "audio/mp4", audioBitrate: Int = 128_000, audioCodec: String = "mp4a.40.2", + audioSegmentRanges: CastSegmentRanges? = null, durationSeconds: Long = 0, ): String { if (!isRunning.get() || videoVariants.isEmpty()) { @@ -225,7 +263,9 @@ class StreamProxyServer private constructor() { val proxyAudioUrl = registerStream(audioUrl, audioMime) val audioMediaPath = "/h/audio_$sessionId.m3u8" - val audioMediaPlaylist = buildMediaPlaylist(proxyAudioUrl, durationSec, targetDuration) + val audioMediaPlaylist = + audioSegmentRanges?.let { buildSegmentedMediaPlaylist(proxyAudioUrl, audioUrl, audioMime, it) } + ?: buildMediaPlaylist(proxyAudioUrl, durationSec, targetDuration) hlsPlaylists[audioMediaPath] = audioMediaPlaylist val audioMediaUrl = "http://$localAddress:$localPort$audioMediaPath" @@ -240,7 +280,9 @@ class StreamProxyServer private constructor() { videoVariants.mapIndexed { index, variant -> val proxyVideoUrl = registerStream(variant.url, variant.mime) val videoMediaPath = "/h/v${variant.height}p_${sessionId}_$index.m3u8" - val videoMediaPlaylist = buildMediaPlaylist(proxyVideoUrl, durationSec, targetDuration) + val videoMediaPlaylist = + variant.segmentRanges?.let { buildSegmentedMediaPlaylist(proxyVideoUrl, variant.url, variant.mime, it) } + ?: buildMediaPlaylist(proxyVideoUrl, durationSec, targetDuration) hlsPlaylists[videoMediaPath] = videoMediaPlaylist val videoMediaUrl = "http://$localAddress:$localPort$videoMediaPath" @@ -295,6 +337,43 @@ class StreamProxyServer private constructor() { appendLine("#EXT-X-ENDLIST") } + /** + * Builds an fMP4 HLS media playlist with one byte-range segment per sidx entry, so the renderer + * can start after the first few seconds instead of downloading the whole file. Null when the + * segment index can't be loaded; the caller then falls back to [buildMediaPlaylist]. + */ + @OptIn(UnstableApi::class) + private fun buildSegmentedMediaPlaylist( + proxyStreamUrl: String, + realUrl: String, + contentType: String, + ranges: CastSegmentRanges, + ): String? { + val representation = + Representation.newInstance( + 0, + Format.Builder().setContainerMimeType(contentType).build(), + listOf(BaseUrl(realUrl)), + SegmentBase.SingleSegmentBase( + RangedUri(null, ranges.init.first, ranges.init.length), + 1, + 0, + ranges.index.first, + ranges.index.length, + ), + ) + val trackType = if (contentType.startsWith("audio/")) C.TRACK_TYPE_AUDIO else C.TRACK_TYPE_VIDEO + val index = + try { + DashUtil.loadChunkIndex(OkHttpDataSource.Factory(http).createDataSource(), trackType, representation) + } catch (e: Exception) { + Log.w(TAG, "Segment index load failed, casting as one segment: ${e.message}") + null + }?.takeIf { it.length > 0 } ?: return null + + return segmentedMediaPlaylist(proxyStreamUrl, ranges.init, index) + } + /** * Builds the HLS master playlist with: * - Audio declared via #EXT-X-MEDIA diff --git a/app/src/test/java/io/github/aedev/flow/player/dlna/CastStreamVariantTest.kt b/app/src/test/java/io/github/aedev/flow/player/dlna/CastStreamVariantTest.kt new file mode 100644 index 0000000000..cecc59e280 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/player/dlna/CastStreamVariantTest.kt @@ -0,0 +1,47 @@ +package io.github.aedev.flow.player.dlna + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.schabi.newpipe.extractor.MediaFormat +import org.schabi.newpipe.extractor.services.youtube.ItagItem +import org.schabi.newpipe.extractor.stream.AudioStream + +class CastStreamVariantTest { + private fun audioStream(itagItem: ItagItem?): AudioStream = + AudioStream + .Builder() + .setId("140") + .setContent("https://example.com/audio.m4a", true) + .setMediaFormat(MediaFormat.M4A) + .setAverageBitrate(128) + .apply { itagItem?.let(::setItagItem) } + .build() + + private fun itag( + initStart: Int, + initEnd: Int, + indexStart: Int, + indexEnd: Int, + ): ItagItem = + ItagItem(ItagItem.getItag(140)).apply { + setInitStart(initStart) + setInitEnd(initEnd) + setIndexStart(indexStart) + setIndexEnd(indexEnd) + } + + @Test + fun `segment ranges are the itag's inclusive init and index ranges`() { + val ranges = audioStream(itag(0, 722, 723, 2962)).castSegmentRanges() + + assertThat(ranges).isEqualTo(CastSegmentRanges(init = 0L..722L, index = 723L..2962L)) + assertThat(ranges!!.init.length).isEqualTo(723) + } + + @Test + fun `streams without an itag or with empty ranges have no segment ranges`() { + assertThat(audioStream(null).castSegmentRanges()).isNull() + assertThat(audioStream(itag(-1, -1, 723, 2962)).castSegmentRanges()).isNull() + assertThat(audioStream(itag(0, 722, 0, 0)).castSegmentRanges()).isNull() + } +} diff --git a/app/src/test/java/io/github/aedev/flow/player/dlna/StreamProxyServerTest.kt b/app/src/test/java/io/github/aedev/flow/player/dlna/StreamProxyServerTest.kt index e5b28117f0..3f8a0ff0bd 100644 --- a/app/src/test/java/io/github/aedev/flow/player/dlna/StreamProxyServerTest.kt +++ b/app/src/test/java/io/github/aedev/flow/player/dlna/StreamProxyServerTest.kt @@ -1,5 +1,8 @@ package io.github.aedev.flow.player.dlna +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.ChunkIndex import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -63,4 +66,36 @@ class StreamProxyServerTest { val hlsContentTypes = listOf("video/mp4", "audio/mp4") assertThat(allowed).containsAtLeastElementsIn(hlsContentTypes.map(StreamProxyServer::segmentFileExtension)) } + + @OptIn(UnstableApi::class) + @Test + fun `segmented playlists map the init segment and each index entry onto byte ranges`() { + val index = + ChunkIndex( + intArrayOf(1_000, 2_000), + longArrayOf(4_434, 5_434), + longArrayOf(6_000_000, 5_500_000), + longArrayOf(0, 6_000_000), + ) + + val playlist = StreamProxyServer.segmentedMediaPlaylist("http://phone/s/v.mp4", 0L..741L, index) + + assertThat(playlist).isEqualTo( + """ + #EXTM3U + #EXT-X-VERSION:6 + #EXT-X-TARGETDURATION:6 + #EXT-X-PLAYLIST-TYPE:VOD + #EXT-X-MAP:URI="http://phone/s/v.mp4",BYTERANGE="742@0" + #EXTINF:6.000, + #EXT-X-BYTERANGE:1000@4434 + http://phone/s/v.mp4 + #EXTINF:5.500, + #EXT-X-BYTERANGE:2000@5434 + http://phone/s/v.mp4 + #EXT-X-ENDLIST + + """.trimIndent(), + ) + } } From 7d4f17339af1d796f2cfcb5eb1c491fa1e626c58 Mon Sep 17 00:00:00 2001 From: Baptiste Le Duc Date: Fri, 23 Oct 2026 00:43:44 +0200 Subject: [PATCH 5/8] refactor(cast): pick the stream in castStreamInfo() and only load it 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 --- .../aedev/flow/player/dlna/DlnaCastManager.kt | 114 +++++++----------- 1 file changed, 41 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt index 778281bd2c..e8812a8cf9 100644 --- a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt @@ -338,94 +338,62 @@ object DlnaCastManager { if (it.contains("mp4") || it.contains("m4a")) "audio/mp4" else it } ?: "audio/mp4" - if (videoVariants.isNotEmpty() && audioUrl != null) { - Log.d(TAG, "HLS cast: ${videoVariants.size} variants, audio=${audioBitrate / 1000}kbps") - castTo( - device = device, - title = title, - videoVariants = videoVariants, - audioUrl = audioUrl, - audioMime = audioMime, - audioBitrate = audioBitrate, - audioCodec = audioCodec, - audioSegmentRanges = bestAudio?.castSegmentRanges(), - durationSeconds = durationSeconds, - ) - } else { - val bestMuxed = - muxedStreams - .filter { it.height > 0 } - .maxByOrNull { VideoCodecUtils.qualityHeightFromStream(it) } - val muxedUrl = bestMuxed?.let { it.content ?: it.url } ?: currentPlayerUrl - if (muxedUrl != null && muxedUrl.isNotEmpty() && !muxedUrl.startsWith("local://")) { - Log.d(TAG, "Fallback to pre-muxed: ${bestMuxed?.let(VideoCodecUtils::qualityHeightFromStream)}p") - castTo(device = device, title = title, fallbackVideoUrl = muxedUrl) - } - } - } + val bestMuxed = + muxedStreams + .filter { it.height > 0 } + .maxByOrNull { VideoCodecUtils.qualityHeightFromStream(it) } + val muxedUrl = + (bestMuxed?.let { it.content ?: it.url } ?: currentPlayerUrl) + ?.takeIf { it.isNotEmpty() && !it.startsWith("local://") } + + val canHls = videoVariants.isNotEmpty() && audioUrl != null + if (!canHls && muxedUrl == null) return - fun castTo( - device: DlnaDevice, - title: String, - videoVariants: List = emptyList(), - audioUrl: String? = null, - audioMime: String = "audio/mp4", - audioBitrate: Int = 128_000, - audioCodec: String = "mp4a.40.2", - audioSegmentRanges: CastSegmentRanges? = null, - durationSeconds: Long = 0, - fallbackVideoUrl: String? = null, - ) { scope.launch { try { - val castUrl: String - val contentType: String - - if (videoVariants.isNotEmpty() && audioUrl != null) { - castUrl = + val (castUrl, contentType) = + if (canHls) { proxy.registerHlsCast( videoVariants = videoVariants, - audioUrl = audioUrl, + audioUrl = audioUrl!!, audioMime = audioMime, audioBitrate = audioBitrate, audioCodec = audioCodec, - audioSegmentRanges = audioSegmentRanges, + audioSegmentRanges = bestAudio?.castSegmentRanges(), durationSeconds = durationSeconds, - ) - contentType = "application/vnd.apple.mpegurl" - Log.i( - TAG, - "Casting via HLS: $castUrl " + - "(${videoVariants.size} variants, audio=${audioBitrate / 1000}kbps)", - ) - } else { - val url = - fallbackVideoUrl - ?: videoVariants.firstOrNull()?.url - ?: return@launch - contentType = guessContentType(url, "video/mp4") - castUrl = proxy.registerStream(url, contentType) - Log.i(TAG, "Casting single stream via proxy: $castUrl") - } - - // Renderers still holding a previous session reject SetAVTransportURI with UPnP 701. - runCatching { stop(device) } - // Some only finish stopping after answering, and still reject the URI until then. - withTimeoutOrNull(STOP_TIMEOUT_MS) { - while (transportState(device).let { it != null && it != "STOPPED" && it != "NO_MEDIA_PRESENT" }) delay(500) - } - setAVTransportUri(device, castUrl, title, contentType) - delay(500) - play(device) - _currentDevice.value = device - startPositionPolling(device) + ) to "application/vnd.apple.mpegurl" + } else { + val type = guessContentType(muxedUrl!!, "video/mp4") + proxy.registerStream(muxedUrl, type) to type + } + Log.i(TAG, "Casting: $castUrl") + startCast(device, title, castUrl, contentType) } catch (e: Exception) { - Log.e(TAG, "castTo failed: ${e.message}") + Log.e(TAG, "Cast failed: ${e.message}") _currentDevice.value = null } } } + private suspend fun startCast( + device: DlnaDevice, + title: String, + castUrl: String, + contentType: String, + ) { + // Renderers still holding a previous session reject SetAVTransportURI with UPnP 701. + runCatching { stop(device) } + // Some only finish stopping after answering, and still reject the URI until then. + withTimeoutOrNull(STOP_TIMEOUT_MS) { + while (transportState(device).let { it != null && it != "STOPPED" && it != "NO_MEDIA_PRESENT" }) delay(500) + } + setAVTransportUri(device, castUrl, title, contentType) + delay(500) + play(device) + _currentDevice.value = device + startPositionPolling(device) + } + private fun transportState(device: DlnaDevice): String? = runCatching { val response = From 5c108bd9b69fcf8ec74362e70e81a8990a098795 Mon Sep 17 00:00:00 2001 From: Baptiste Le Duc Date: Fri, 23 Oct 2026 00:43:54 +0200 Subject: [PATCH 6/8] feat(cast): fall back to simpler formats when the renderer cannot play 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 --- .../aedev/flow/player/dlna/DlnaCastManager.kt | 104 +++++++++++++----- .../flow/player/dlna/StreamProxyServer.kt | 15 +-- 2 files changed, 84 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt index e8812a8cf9..d23a3e0277 100644 --- a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt @@ -5,11 +5,13 @@ import android.net.wifi.WifiManager import android.os.SystemClock import android.util.Log import io.github.aedev.flow.player.stream.VideoCodecUtils +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -29,6 +31,7 @@ import java.net.InetAddress import java.net.MulticastSocket import java.net.URI import java.net.URL +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit /** @@ -51,6 +54,7 @@ object DlnaCastManager { private const val SSDP_PORT = 1900 private const val DISCOVERY_TIMEOUT_MS = 5_000L private const val STOP_TIMEOUT_MS = 5_000L + private const val SEGMENTED_START_TIMEOUT_MS = 10_000L private val SOAP_TYPE = "text/xml; charset=\"utf-8\"".toMediaType() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -85,6 +89,12 @@ object DlnaCastManager { private var positionPollingJob: Job? = null + private enum class CastMode { SEGMENTED, WHOLE_FILE, MUXED } + + // In memory only: the first cast to a renderer after an app start probes the ladder again. + private val failedCastModes = ConcurrentHashMap>() + private var castJob: Job? = null + // ── Discovery ────────────────────────────────────────────────────────────── /** @@ -287,9 +297,10 @@ object DlnaCastManager { * The proxy runs on the phone and relays the stream, so the renderer * connects to the phone instead of YouTube. * - * Build cast variants from the player's streams and start casting to [device]. - * Prefers HLS-style separate video variants + best AAC audio; falls back to a pre-muxed - * stream (or [currentPlayerUrl]) when separate streams aren't usable. + * Tries each [CastMode] in order, skipping those a renderer already failed: + * segmented fMP4 HLS starts in seconds but older renderers can't play it, single-segment HLS + * works there but downloads the whole file first, and a pre-muxed stream (or + * [currentPlayerUrl]) is the last resort. * * @param currentPlayerUrl the currently-playing media URI, supplied by the caller so this * manager stays decoupled from the player engine; used as a last-resort fallback. @@ -346,33 +357,66 @@ object DlnaCastManager { (bestMuxed?.let { it.content ?: it.url } ?: currentPlayerUrl) ?.takeIf { it.isNotEmpty() && !it.startsWith("local://") } + val audioSegmentRanges = bestAudio?.castSegmentRanges() val canHls = videoVariants.isNotEmpty() && audioUrl != null - if (!canHls && muxedUrl == null) return + val modes = + listOfNotNull( + CastMode.SEGMENTED.takeIf { canHls && videoVariants.first().segmentRanges != null && audioSegmentRanges != null }, + CastMode.WHOLE_FILE.takeIf { canHls }, + CastMode.MUXED.takeIf { muxedUrl != null }, + ) + val deviceKey = device.usn.substringBefore("::") + // A mode is skipped only once it failed on this renderer, never because a video lacked it. + val ladder = (modes - failedCastModes[deviceKey].orEmpty()).ifEmpty { modes } + if (ladder.isEmpty()) return - scope.launch { - try { - val (castUrl, contentType) = - if (canHls) { - proxy.registerHlsCast( - videoVariants = videoVariants, - audioUrl = audioUrl!!, - audioMime = audioMime, - audioBitrate = audioBitrate, - audioCodec = audioCodec, - audioSegmentRanges = bestAudio?.castSegmentRanges(), - durationSeconds = durationSeconds, - ) to "application/vnd.apple.mpegurl" - } else { - val type = guessContentType(muxedUrl!!, "video/mp4") - proxy.registerStream(muxedUrl, type) to type + castJob?.cancel() + castJob = + scope.launch { + for (mode in ladder) { + // A video that can't be registered says nothing about the renderer, so it isn't remembered. + val (castUrl, contentType) = + try { + when (mode) { + CastMode.SEGMENTED, CastMode.WHOLE_FILE -> { + val segmented = mode == CastMode.SEGMENTED + proxy.registerHlsCast( + videoVariants = + if (segmented) videoVariants else videoVariants.map { it.copy(segmentRanges = null) }, + audioUrl = audioUrl!!, + audioMime = audioMime, + audioBitrate = audioBitrate, + audioCodec = audioCodec, + audioSegmentRanges = if (segmented) audioSegmentRanges else null, + durationSeconds = durationSeconds, + ) to "application/vnd.apple.mpegurl" + } + + CastMode.MUXED -> { + val type = guessContentType(muxedUrl!!, "video/mp4") + proxy.registerStream(muxedUrl, type) to type + } + } + } catch (e: Exception) { + Log.e(TAG, "Cast $mode registration failed: ${e.message}") + continue + } + Log.i(TAG, "Casting $mode: $castUrl") + // Registration blocks, so a newer cast or stop() may have cancelled this one meanwhile. + ensureActive() + try { + startCast(device, title, castUrl, contentType) + if (mode != CastMode.SEGMENTED || awaitPlaying(device)) return@launch + Log.w(TAG, "Renderer did not start $mode within ${SEGMENTED_START_TIMEOUT_MS}ms, falling back") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Cast $mode failed: ${e.message}") } - Log.i(TAG, "Casting: $castUrl") - startCast(device, title, castUrl, contentType) - } catch (e: Exception) { - Log.e(TAG, "Cast failed: ${e.message}") + failedCastModes.getOrPut(deviceKey) { ConcurrentHashMap.newKeySet() } += mode + } _currentDevice.value = null } - } } private suspend fun startCast( @@ -389,11 +433,18 @@ object DlnaCastManager { } setAVTransportUri(device, castUrl, title, contentType) delay(500) - play(device) + // Renderers that buffer before answering can time Play out and still start playback. + runCatching { play(device) }.onFailure { Log.w(TAG, "Play failed: ${it.message}") } _currentDevice.value = device startPositionPolling(device) } + private suspend fun awaitPlaying(device: DlnaDevice): Boolean = + withTimeoutOrNull(SEGMENTED_START_TIMEOUT_MS) { + while (transportState(device) != "PLAYING") delay(1_000) + true + } ?: false + private fun transportState(device: DlnaDevice): String? = runCatching { val response = @@ -406,6 +457,7 @@ object DlnaCastManager { }.getOrNull() fun stop() { + castJob?.cancel() val device = _currentDevice.value ?: return scope.launch { try { diff --git a/app/src/main/java/io/github/aedev/flow/player/dlna/StreamProxyServer.kt b/app/src/main/java/io/github/aedev/flow/player/dlna/StreamProxyServer.kt index 57ecd8a96e..5130b63992 100644 --- a/app/src/main/java/io/github/aedev/flow/player/dlna/StreamProxyServer.kt +++ b/app/src/main/java/io/github/aedev/flow/player/dlna/StreamProxyServer.kt @@ -339,8 +339,8 @@ class StreamProxyServer private constructor() { /** * Builds an fMP4 HLS media playlist with one byte-range segment per sidx entry, so the renderer - * can start after the first few seconds instead of downloading the whole file. Null when the - * segment index can't be loaded; the caller then falls back to [buildMediaPlaylist]. + * can start after the first few seconds instead of downloading the whole file. Throws when the + * segment index can't be loaded, so a segmented cast fails instead of downloading the whole file. */ @OptIn(UnstableApi::class) private fun buildSegmentedMediaPlaylist( @@ -348,7 +348,7 @@ class StreamProxyServer private constructor() { realUrl: String, contentType: String, ranges: CastSegmentRanges, - ): String? { + ): String { val representation = Representation.newInstance( 0, @@ -364,12 +364,9 @@ class StreamProxyServer private constructor() { ) val trackType = if (contentType.startsWith("audio/")) C.TRACK_TYPE_AUDIO else C.TRACK_TYPE_VIDEO val index = - try { - DashUtil.loadChunkIndex(OkHttpDataSource.Factory(http).createDataSource(), trackType, representation) - } catch (e: Exception) { - Log.w(TAG, "Segment index load failed, casting as one segment: ${e.message}") - null - }?.takeIf { it.length > 0 } ?: return null + DashUtil + .loadChunkIndex(OkHttpDataSource.Factory(http).createDataSource(), trackType, representation) + ?.takeIf { it.length > 0 } ?: error("No segment index in $contentType stream") return segmentedMediaPlaylist(proxyStreamUrl, ranges.init, index) } From 272d4bcbd888a21a26a5ab9d9e34b4162b142b77 Mon Sep 17 00:00:00 2001 From: Baptiste Le Duc Date: Fri, 23 Oct 2026 00:43:59 +0200 Subject: [PATCH 7/8] feat(cast): try AV1 first on renderers that may decode it 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 --- .../aedev/flow/player/dlna/DlnaCastManager.kt | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt index d23a3e0277..3fe758f94b 100644 --- a/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt @@ -89,7 +89,7 @@ object DlnaCastManager { private var positionPollingJob: Job? = null - private enum class CastMode { SEGMENTED, WHOLE_FILE, MUXED } + private enum class CastMode { SEGMENTED_AV1, SEGMENTED, WHOLE_FILE, MUXED } // In memory only: the first cast to a renderer after an app start probes the ladder again. private val failedCastModes = ConcurrentHashMap>() @@ -297,10 +297,10 @@ object DlnaCastManager { * The proxy runs on the phone and relays the stream, so the renderer * connects to the phone instead of YouTube. * - * Tries each [CastMode] in order, skipping those a renderer already failed: - * segmented fMP4 HLS starts in seconds but older renderers can't play it, single-segment HLS - * works there but downloads the whole file first, and a pre-muxed stream (or - * [currentPlayerUrl]) is the last resort. + * Tries each [CastMode] in order, skipping those a renderer already failed: AV1 goes above + * 1080p but most renderers can't decode it, segmented fMP4 HLS starts in seconds but older + * renderers can't play it, single-segment HLS works there but downloads the whole file first, + * and a pre-muxed stream (or [currentPlayerUrl]) is the last resort. * * @param currentPlayerUrl the currently-playing media URI, supplied by the caller so this * manager stays decoupled from the player engine; used as a last-resort fallback. @@ -314,12 +314,12 @@ object DlnaCastManager { muxedStreams: List, durationSeconds: Long, ) { - val videoVariants = + // Renderers fail playlists that mix codecs, so each playlist carries a single variant. + fun bestVariants(codecKey: String): List = listOfNotNull( videoOnlyStreams .filter { it.height > 0 && !(it.content ?: it.url).isNullOrEmpty() } - // Only the best variant is sent, and above 1080p it is AV1, which most renderers can't decode. - .filter { VideoCodecUtils.codecKeyFromStream(it) == "h264" } + .filter { VideoCodecUtils.codecKeyFromStream(it) == codecKey } // Renderers start on the first variant listed, and each one is probed before they get the playlist. .maxByOrNull { VideoCodecUtils.qualityHeightFromStream(it) }, ).map { stream -> @@ -333,6 +333,9 @@ object DlnaCastManager { segmentRanges = stream.castSegmentRanges(), ) } + val videoVariants = bestVariants("h264") + // H.264 stops at 1080p, so AV1 is only worth trying when it goes higher. + val av1Variants = bestVariants("av1").filter { it.height > (videoVariants.firstOrNull()?.height ?: 0) } val bestAudio = audioStreams @@ -361,6 +364,7 @@ object DlnaCastManager { val canHls = videoVariants.isNotEmpty() && audioUrl != null val modes = listOfNotNull( + CastMode.SEGMENTED_AV1.takeIf { canHls && av1Variants.firstOrNull()?.segmentRanges != null && audioSegmentRanges != null }, CastMode.SEGMENTED.takeIf { canHls && videoVariants.first().segmentRanges != null && audioSegmentRanges != null }, CastMode.WHOLE_FILE.takeIf { canHls }, CastMode.MUXED.takeIf { muxedUrl != null }, @@ -374,28 +378,33 @@ object DlnaCastManager { castJob = scope.launch { for (mode in ladder) { + val (variants, audioRanges) = + when (mode) { + CastMode.SEGMENTED_AV1 -> av1Variants to audioSegmentRanges + CastMode.SEGMENTED -> videoVariants to audioSegmentRanges + else -> videoVariants.map { it.copy(segmentRanges = null) } to null + } + val segmented = audioRanges != null // A video that can't be registered says nothing about the renderer, so it isn't remembered. val (castUrl, contentType) = try { when (mode) { - CastMode.SEGMENTED, CastMode.WHOLE_FILE -> { - val segmented = mode == CastMode.SEGMENTED + CastMode.MUXED -> { + val type = guessContentType(muxedUrl!!, "video/mp4") + proxy.registerStream(muxedUrl, type) to type + } + + else -> { proxy.registerHlsCast( - videoVariants = - if (segmented) videoVariants else videoVariants.map { it.copy(segmentRanges = null) }, + videoVariants = variants, audioUrl = audioUrl!!, audioMime = audioMime, audioBitrate = audioBitrate, audioCodec = audioCodec, - audioSegmentRanges = if (segmented) audioSegmentRanges else null, + audioSegmentRanges = audioRanges, durationSeconds = durationSeconds, ) to "application/vnd.apple.mpegurl" } - - CastMode.MUXED -> { - val type = guessContentType(muxedUrl!!, "video/mp4") - proxy.registerStream(muxedUrl, type) to type - } } } catch (e: Exception) { Log.e(TAG, "Cast $mode registration failed: ${e.message}") @@ -406,7 +415,7 @@ object DlnaCastManager { ensureActive() try { startCast(device, title, castUrl, contentType) - if (mode != CastMode.SEGMENTED || awaitPlaying(device)) return@launch + if (!segmented || awaitPlaying(device)) return@launch Log.w(TAG, "Renderer did not start $mode within ${SEGMENTED_START_TIMEOUT_MS}ms, falling back") } catch (e: CancellationException) { throw e From 292cf6c6d24d1e1b478fd3da6ebd7134314c8819 Mon Sep 17 00:00:00 2001 From: Baptiste Le Duc Date: Fri, 23 Oct 2026 00:43:59 +0200 Subject: [PATCH 8/8] docs(nightly): add the DLNA casting notes Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Baptiste Le Duc --- .github/nightly-notes/2026-10-22-fix-dlna-cast-audio.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .github/nightly-notes/2026-10-22-fix-dlna-cast-audio.md diff --git a/.github/nightly-notes/2026-10-22-fix-dlna-cast-audio.md b/.github/nightly-notes/2026-10-22-fix-dlna-cast-audio.md new file mode 100644 index 0000000000..f8eae0f794 --- /dev/null +++ b/.github/nightly-notes/2026-10-22-fix-dlna-cast-audio.md @@ -0,0 +1,7 @@ +## Fixed +- Videos cast to a TV over DLNA play with sound again +- Casting to a TV that is still busy with a previous video no longer fails + +## Improved +- Videos cast over DLNA start within seconds and play in the best available quality +- Casting falls back to a simpler format on TVs that cannot play the faster one