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 000000000..f8eae0f79 --- /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 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 cf26b6ba0..bb734bb9e 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/CastStreamVariant.kt b/app/src/main/java/io/github/aedev/flow/player/dlna/CastStreamVariant.kt index df03de908..53f23a74f 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 2027bc51b..3fe758f94 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,21 +5,25 @@ 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 import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull 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 @@ -27,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 /** @@ -48,6 +53,8 @@ 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 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()) @@ -82,6 +89,12 @@ object DlnaCastManager { private var positionPollingJob: Job? = null + 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>() + private var castJob: Job? = null + // ── Discovery ────────────────────────────────────────────────────────────── /** @@ -284,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 an extracted [StreamInfo] 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: 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. @@ -294,142 +308,169 @@ 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, + // 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() } + .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 -> + 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", + segmentRanges = stream.castSegmentRanges(), ) - } 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) - } } - } else { - if (currentPlayerUrl != null && currentPlayerUrl.isNotEmpty() && !currentPlayerUrl.startsWith("local://")) { - castTo(device = device, title = title, fallbackVideoUrl = currentPlayerUrl) + 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 + .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" + + 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 audioSegmentRanges = bestAudio?.castSegmentRanges() + 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 }, + ) + 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 + + castJob?.cancel() + 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.MUXED -> { + val type = guessContentType(muxedUrl!!, "video/mp4") + proxy.registerStream(muxedUrl, type) to type + } + + else -> { + proxy.registerHlsCast( + videoVariants = variants, + audioUrl = audioUrl!!, + audioMime = audioMime, + audioBitrate = audioBitrate, + audioCodec = audioCodec, + audioSegmentRanges = audioRanges, + durationSeconds = durationSeconds, + ) to "application/vnd.apple.mpegurl" + } + } + } 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 (!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}") + } + failedCastModes.getOrPut(deviceKey) { ConcurrentHashMap.newKeySet() } += mode + } + _currentDevice.value = null } - } } - fun castTo( + private suspend fun startCast( device: DlnaDevice, title: String, - videoVariants: List = emptyList(), - audioUrl: String? = null, - audioMime: String = "audio/mp4", - audioBitrate: Int = 128_000, - audioCodec: String = "mp4a.40.2", - durationSeconds: Long = 0, - fallbackVideoUrl: String? = null, + castUrl: String, + contentType: String, ) { - scope.launch { - try { - val castUrl: String - val contentType: String - - if (videoVariants.isNotEmpty() && audioUrl != null) { - castUrl = - proxy.registerHlsCast( - videoVariants = videoVariants, - audioUrl = audioUrl, - audioMime = audioMime, - audioBitrate = audioBitrate, - audioCodec = audioCodec, - 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") - } - - setAVTransportUri(device, castUrl, title, contentType) - delay(500) - play(device) - _currentDevice.value = device - startPositionPolling(device) - } catch (e: Exception) { - Log.e(TAG, "castTo failed: ${e.message}") - _currentDevice.value = null - } + // 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) + // 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 = + 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() { + castJob?.cancel() 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 { @@ -590,6 +631,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( 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 7b064651e..5130b6399 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,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 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 2d9e98381..8b85b1ce6 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() }, 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 000000000..cecc59e28 --- /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 e5b28117f..3f8a0ff0b 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(), + ) + } }