From 840d1ae534c180bae991be251a35a0b724056301 Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 23 Nov 2023 16:44:58 +0100 Subject: [PATCH] Fixes to adhere closer to the HLS spec and Twitch VODs no longer start at end. --- .../casting/ChomecastCastingDevice.kt | 2 +- .../platformplayer/casting/StateCasting.kt | 84 +++++++++++-------- .../com/futo/platformplayer/parsers/HLS.kt | 53 +++++++++--- 3 files changed, 92 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index 69b447e1..a67558c4 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -69,7 +69,7 @@ class ChromecastCastingDevice : CastingDevice { return; } - Logger.i(FastCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)"); + Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)"); time = resumePosition; _streamType = streamType; diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 2afd3734..d3150157 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -336,21 +336,23 @@ class StateCasting { if (sourceCount > 1) { if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { if (ad is AirPlayCastingDevice) { + Logger.i(TAG, "Casting as local HLS"); castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition); } else { + Logger.i(TAG, "Casting as local DASH"); castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition); } } else { StateApp.instance.scope.launch(Dispatchers.IO) { try { if (ad is FastCastCastingDevice) { - Logger.i(TAG, "Casting as dash direct"); + Logger.i(TAG, "Casting as DASH direct"); castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); } else if (ad is AirPlayCastingDevice) { Logger.i(TAG, "Casting as HLS indirect"); castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); } else { - Logger.i(TAG, "Casting as dash indirect"); + Logger.i(TAG, "Casting as DASH indirect"); castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); } } catch (e: Throwable) { @@ -498,8 +500,8 @@ class StateCasting { val duration = videoSource.duration val videoVariantPlaylistPath = "/video-playlist-${id}" val videoVariantPlaylistUrl = url + videoVariantPlaylistPath - val videoVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), videoUrl)) - val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, videoVariantPlaylistSegments) + val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) + val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, videoVariantPlaylistSegments) _castServer.addHandler( HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), @@ -508,7 +510,7 @@ class StateCasting { ).withTag("castLocalHls") variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo( - videoSource.bitrate, "${videoSource.width}x${videoSource.height}", videoSource.codec, null, null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, null))) + videoSource.bitrate, "${videoSource.width}x${videoSource.height}", videoSource.codec, null, null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, null, null))) } if (audioSource != null) { @@ -520,8 +522,8 @@ class StateCasting { val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") val audioVariantPlaylistPath = "/audio-playlist-${id}" val audioVariantPlaylistUrl = url + audioVariantPlaylistPath - val audioVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), audioUrl)) - val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, audioVariantPlaylistSegments) + val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) + val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, audioVariantPlaylistSegments) _castServer.addHandler( HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), @@ -541,8 +543,8 @@ class StateCasting { val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown") val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath - val subtitleVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), subtitleUrl)) - val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, subtitleVariantPlaylistSegments) + val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl)) + val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, subtitleVariantPlaylistSegments) _castServer.addHandler( HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), @@ -704,7 +706,7 @@ class StateCasting { vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, variantPlaylistRef.url) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist) + val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") @@ -716,17 +718,19 @@ class StateCasting { } for (mediaRendition in masterPlaylist.mediaRenditions) { - val playlistId = UUID.randomUUID(); - val newPlaylistPath = "/hls-playlist-${playlistId}" - val newPlaylistUrl = url + newPlaylistPath; + val playlistId = UUID.randomUUID() + var newPlaylistUrl: String? = null if (mediaRendition.uri != null) { + val newPlaylistPath = "/hls-playlist-${playlistId}" + newPlaylistUrl = url + newPlaylistPath + _castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext -> val vpHeaders = vpContext.headers.clone() vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, mediaRendition.uri) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist) + val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") @@ -748,12 +752,15 @@ class StateCasting { }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster") Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble()); + + //ChromeCast is sometimes funky with resume position 0 + val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 1.0 else resumePosition; + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble()); return listOf(hlsUrl); } - private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, proxySegments: Boolean = true): HLS.VariantPlaylist { + private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist { val newSegments = arrayListOf() if (proxySegments) { @@ -771,26 +778,31 @@ class StateCasting { variantPlaylist.mediaSequence, variantPlaylist.discontinuitySequence, variantPlaylist.programDateTime, + variantPlaylist.playlistType, newSegments ) } private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment { - val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}" - val newSegmentUrl = url + newSegmentPath; + if (segment is HLS.MediaSegment) { + val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}" + val newSegmentUrl = url + newSegmentPath; - if (_castServer.getHandler("GET", newSegmentPath) == null) { - _castServer.addHandler( - HttpProxyHandler("GET", newSegmentPath, segment.uri, true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castProxiedHlsVariant") + if (_castServer.getHandler("GET", newSegmentPath) == null) { + _castServer.addHandler( + HttpProxyHandler("GET", newSegmentPath, segment.uri, true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsVariant") + } + + return HLS.MediaSegment( + segment.duration, + newSegmentUrl + ) + } else { + return segment } - - return HLS.Segment( - segment.duration, - newSegmentUrl - ) } private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List { @@ -813,8 +825,8 @@ class StateCasting { val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") val audioVariantPlaylistPath = "/audio-playlist-${id}" val audioVariantPlaylistUrl = url + audioVariantPlaylistPath - val audioVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), audioUrl)) - val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, audioVariantPlaylistSegments) + val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) + val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, audioVariantPlaylistSegments) _castServer.addHandler( HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), @@ -866,8 +878,8 @@ class StateCasting { val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown") val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath - val subtitleVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), subtitlesUrl)) - val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, subtitleVariantPlaylistSegments) + val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl)) + val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, subtitleVariantPlaylistSegments) _castServer.addHandler( HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), @@ -886,8 +898,8 @@ class StateCasting { val duration = videoSource.duration val videoVariantPlaylistPath = "/video-playlist-${id}" val videoVariantPlaylistUrl = url + videoVariantPlaylistPath - val videoVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), videoUrl)) - val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, videoVariantPlaylistSegments) + val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) + val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, videoVariantPlaylistSegments) _castServer.addHandler( HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), @@ -904,7 +916,7 @@ class StateCasting { null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, - null))) + null, null))) _castServer.addHandler( HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt index c201dbd2..d8b1fbaf 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -66,18 +66,22 @@ class HLS { val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let { ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME) } + val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":") val segments = mutableListOf() - var currentSegment: Segment? = null + var currentSegment: MediaSegment? = null lines.forEach { line -> when { line.startsWith("#EXTINF:") -> { val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull() ?: throw Exception("Invalid segment duration format") - currentSegment = Segment(duration = duration) + currentSegment = MediaSegment(duration = duration) } - line.startsWith("#") -> { - // Handle other tags if necessary + line == "#EXT-X-DISCONTINUITY" -> { + segments.add(DiscontinuitySegment()) + } + line =="#EXT-X-ENDLIST" -> { + segments.add(EndListSegment()) } else -> { currentSegment?.let { @@ -89,7 +93,7 @@ class HLS { } } - return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, segments) + return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, segments) } private fun resolveUrl(baseUrl: String, url: String): String { @@ -113,6 +117,7 @@ class HLS { frameRate = attributes["FRAME-RATE"], videoRange = attributes["VIDEO-RANGE"], audio = attributes["AUDIO"], + video = attributes["VIDEO"], subtitles = attributes["SUBTITLES"], closedCaptions = attributes["CLOSED-CAPTIONS"] ) @@ -159,7 +164,7 @@ class HLS { return attributes } - private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO") + private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO") private fun shouldQuote(key: String, value: String?): Boolean { if (value == null) return false; @@ -200,6 +205,7 @@ class HLS { val frameRate: String?, val videoRange: String?, val audio: String?, + val video: String?, val subtitles: String?, val closedCaptions: String? ) @@ -270,6 +276,7 @@ class HLS { "FRAME-RATE" to streamInfo.frameRate, "VIDEO-RANGE" to streamInfo.videoRange, "AUDIO" to streamInfo.audio, + "VIDEO" to streamInfo.video, "SUBTITLES" to streamInfo.subtitles, "CLOSED-CAPTIONS" to streamInfo.closedCaptions ) @@ -283,6 +290,7 @@ class HLS { val mediaSequence: Long, val discontinuitySequence: Int, val programDateTime: ZonedDateTime?, + val playlistType: String?, val segments: List ) { fun buildM3U8(): String = buildString { @@ -291,19 +299,44 @@ class HLS { append("#EXT-X-TARGETDURATION:$targetDuration\n") append("#EXT-X-MEDIA-SEQUENCE:$mediaSequence\n") append("#EXT-X-DISCONTINUITY-SEQUENCE:$discontinuitySequence\n") + + playlistType?.let { + append("#EXT-X-PLAYLIST-TYPE:$it\n") + } + programDateTime?.let { append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") } segments.forEach { segment -> - append("#EXTINF:${segment.duration},\n") - append(segment.uri + "\n") + append(segment.toM3U8Line()) } } } - data class Segment( + abstract class Segment { + abstract fun toM3U8Line(): String + } + + data class MediaSegment ( val duration: Double, var uri: String = "" - ) + ) : Segment() { + override fun toM3U8Line(): String = buildString { + append("#EXTINF:${duration},\n") + append(uri + "\n") + } + } + + class DiscontinuitySegment : Segment() { + override fun toM3U8Line(): String = buildString { + append("#EXT-X-DISCONTINUITY\n") + } + } + + class EndListSegment : Segment() { + override fun toM3U8Line(): String = buildString { + append("#EXT-X-ENDLIST\n") + } + } }