Skip to content
7 changes: 7 additions & 0 deletions .github/nightly-notes/2026-10-22-fix-dlna-cast-audio.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ class EnhancedPlayerManager private constructor() {
private var currentVideoId: String? = null
private var availableVideoStreams: List<VideoStream> = emptyList()
private var availableAudioStreams: List<AudioStream> = emptyList()

// processAudioStreams keeps one stream per track, usually Opus; DLNA casting needs the AAC ones.
private var castAudioStreams: List<AudioStream> = emptyList()
private val subtitleTracks = SubtitleTracks()
private val subtitleDelay = SubtitleDelay()
private var subtitleDelayVideoId: String? = null
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -2623,6 +2628,10 @@ class EnhancedPlayerManager private constructor() {

fun getPlayer(): ExoPlayer? = player

fun getCastVideoStreams(): List<VideoStream> = availableVideoStreams

fun getCastAudioStreams(): List<AudioStream> = castAudioStreams

fun getCurrentPosition(): Long = player?.currentPosition ?: 0L

fun getDuration(): Long = player?.duration ?: 0L
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
)
}
295 changes: 172 additions & 123 deletions app/src/main/java/io/github/aedev/flow/player/dlna/DlnaCastManager.kt

Large diffs are not rendered by default.

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

Expand Down Expand Up @@ -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()) {
Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -295,6 +337,40 @@ 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. 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(
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 =
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)
}

/**
* Builds the HLS master playlist with:
* - Audio declared via #EXT-X-MEDIA
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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(),
)
}
}
Loading