mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #3796 from davotoula/fix/hls-strip-ll-tags
Work around a fatal media3 crash on Low-Latency HLS (LL-HLS), eg NoGoodRadio
This commit is contained in:
+2
-2
@@ -53,9 +53,9 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTop
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.fullscreenSwipeControls
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.AudioPlayingAnimation
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.rememberIsAudioTrack
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.ui.components.getDialogWindow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@@ -105,7 +105,7 @@ fun RenderVideoPlayer(
|
||||
// unnecessary recomposition of the whole player tree just to update a value that is only
|
||||
// ever read inside the onDoubleTap callback below.
|
||||
val containerWidth = remember { intArrayOf(0) }
|
||||
val isLive = remember(mediaItem.src.videoUri) { isLiveStreaming(mediaItem.src.videoUri) }
|
||||
val isLive = remember(mediaItem.src.videoUri, mediaItem.src.mimeType) { isHlsMedia(mediaItem.src.videoUri, mediaItem.src.mimeType) }
|
||||
|
||||
val swipeState = remember { FullscreenSwipeControlsState() }
|
||||
val context = LocalContext.current
|
||||
|
||||
+2
-2
@@ -63,7 +63,7 @@ import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
|
||||
import com.vitorpamplona.amethyst.ui.cast.CastDevicePickerDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
@@ -113,7 +113,7 @@ fun RenderTopButtons(
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isLive = remember(mediaData.videoUri) { isLiveStreaming(mediaData.videoUri) }
|
||||
val isLive = remember(mediaData.videoUri, mediaData.mimeType) { isHlsMedia(mediaData.videoUri, mediaData.mimeType) }
|
||||
val pipSupported =
|
||||
remember {
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
|
||||
|
||||
import androidx.media3.common.MimeTypes
|
||||
|
||||
/**
|
||||
* Whether a URL plus its imeta mime identifies HLS.
|
||||
*
|
||||
* This is the caller-side form for code that holds a URL and a mime but no `MediaItem` yet — UI that
|
||||
* has a [MediaItemData] or a `MediaUrlContent`. Once a `MediaItem` exists,
|
||||
* `isHlsMediaItem` is the equivalent, and both must answer the same way: the UI decides what to
|
||||
* render, the factory decides how to load it, and a disagreement shows up as a player that streams
|
||||
* something the surrounding chrome says is a still image.
|
||||
*
|
||||
* Defined in terms of [MediaItemCache.toExoPlayerMimeType] rather than re-deriving the rules, so it
|
||||
* cannot drift from the mime that actually reaches ExoPlayer. That normalizer prefers an explicit
|
||||
* mime (mapping the four HLS aliases onto [MimeTypes.APPLICATION_M3U8]) and otherwise falls back to a
|
||||
* **path-anchored** `.m3u8` test.
|
||||
*
|
||||
* Both halves matter. A BUD-10 blossom playlist is `https://host/<sha256>` with no extension at all,
|
||||
* so only the mime identifies it; and anchoring to the path stops `video.mp4?ref=a.m3u8` counting as
|
||||
* HLS on the strength of its query string.
|
||||
*
|
||||
* Note this answers *is it HLS*, which callers use as a proxy for *is it live*. The proxy is
|
||||
* imprecise in the same way for every HLS URL — an on-demand HLS playlist also answers true — and
|
||||
* that imprecision is older than this function. Liveness is only truly knowable from
|
||||
* `#EXT-X-ENDLIST` once the playlist is loaded, which is what `HlsLivenessCache` records.
|
||||
*/
|
||||
fun isHlsMedia(
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
): Boolean = MediaItemCache.toExoPlayerMimeType(mimeType, url) == MimeTypes.APPLICATION_M3U8
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.diskCache
|
||||
|
||||
fun isLiveStreaming(url: String) = url.contains(".m3u8", true)
|
||||
+65
-15
@@ -20,10 +20,13 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.playerPool
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
@@ -31,7 +34,6 @@ import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.HlsLivenessCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
@@ -58,6 +60,26 @@ internal fun shouldBypassCache(
|
||||
else -> true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a [MediaItem] is HLS, via media3's own content-type inference.
|
||||
*
|
||||
* This is the one "is this HLS?" predicate for playback routing. [CustomMediaSourceFactory] uses it
|
||||
* to pick both the cache route and the media-source factory, and [HlsLivenessRecorder] uses it to
|
||||
* decide which items are worth learning a liveness verdict for. Those two **must** agree: if the
|
||||
* recorder tested more narrowly than the factory, an item the factory treats as HLS would never be
|
||||
* classified, [HlsLivenessCache] would answer `isKnownOnDemand = false` for it forever, and
|
||||
* [shouldBypassCache] would keep it out of the disk cache permanently.
|
||||
*
|
||||
* It reads back the mimeType [MediaItemCache] already normalised, which is what makes it correct for
|
||||
* BUD-10 blossom URIs — `https://host/<sha256>`, no extension, mime as the only signal. A `.m3u8`
|
||||
* substring test on the URL misses those, and separately gives a false positive on a query string
|
||||
* over progressive media (`video.mp4?ref=a.m3u8`).
|
||||
*/
|
||||
internal fun isHlsMediaItem(mediaItem: MediaItem?): Boolean {
|
||||
val config = mediaItem?.localConfiguration ?: return false
|
||||
return Util.inferContentTypeForUriAndMimeType(config.uri, config.mimeType) == C.CONTENT_TYPE_HLS
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether a [MediaItem] plays through the caching data source or bypasses it.
|
||||
*
|
||||
@@ -81,20 +103,42 @@ class CustomMediaSourceFactory(
|
||||
videoCache: VideoCache,
|
||||
dataSourceFactory: DataSource.Factory,
|
||||
) : MediaSource.Factory {
|
||||
private var cachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(videoCache.get(dataSourceFactory))
|
||||
private var nonCachingFactory: MediaSource.Factory =
|
||||
private val cachingDataSource: DataSource.Factory = videoCache.get(dataSourceFactory)
|
||||
|
||||
private val cachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(cachingDataSource)
|
||||
private val nonCachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(dataSourceFactory)
|
||||
|
||||
// Stateless, so one instance serves both HLS factories.
|
||||
private val playlistParserFactory = LowLatencyStrippingHlsPlaylistParserFactory()
|
||||
|
||||
// HLS is built explicitly rather than through DefaultMediaSourceFactory, which exposes no hook
|
||||
// for a playlist parser factory. See LowLatencyStrippingHlsPlaylistParserFactory for why we need
|
||||
// one. Everything else still routes through the Default factories above.
|
||||
//
|
||||
// The cost of bypassing it: HLS items skip what DefaultMediaSourceFactory wraps around the
|
||||
// source — side-loaded subtitleConfigurations (MergingMediaSource), clipping, ad insertion, and
|
||||
// live target-offset defaults. None are reachable today (MediaItemCache sets none of them, and
|
||||
// the live setters aren't on the MediaSource.Factory interface), but anything added later must
|
||||
// be mirrored here.
|
||||
private val cachingHlsFactory: MediaSource.Factory = hlsFactory(cachingDataSource)
|
||||
private val nonCachingHlsFactory: MediaSource.Factory = hlsFactory(dataSourceFactory)
|
||||
|
||||
private fun hlsFactory(dataSource: DataSource.Factory): MediaSource.Factory =
|
||||
HlsMediaSource
|
||||
.Factory(dataSource)
|
||||
.setPlaylistParserFactory(playlistParserFactory)
|
||||
|
||||
private val allFactories = listOf(cachingFactory, nonCachingFactory, cachingHlsFactory, nonCachingHlsFactory)
|
||||
|
||||
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
|
||||
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
nonCachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
allFactories.forEach { it.setDrmSessionManagerProvider(drmSessionManagerProvider) }
|
||||
return this
|
||||
}
|
||||
|
||||
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
|
||||
cachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
nonCachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
allFactories.forEach { it.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) }
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -103,18 +147,24 @@ class CustomMediaSourceFactory(
|
||||
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
|
||||
val id = mediaItem.mediaId
|
||||
val flaggedLive = isFlaggedLive(mediaItem)
|
||||
val hls = isLiveStreaming(id)
|
||||
|
||||
// One predicate governs both the cache routing below and which factory builds the source, so
|
||||
// the two can never disagree. See isHlsMediaItem.
|
||||
val hls = isHlsMediaItem(mediaItem)
|
||||
|
||||
val knownOnDemand = HlsLivenessCache.isKnownOnDemand(id)
|
||||
val bypassCache = shouldBypassCache(flaggedLive, hls, knownOnDemand)
|
||||
|
||||
val source =
|
||||
if (bypassCache) {
|
||||
nonCachingFactory.createMediaSource(mediaItem)
|
||||
val factory =
|
||||
if (hls) {
|
||||
if (bypassCache) nonCachingHlsFactory else cachingHlsFactory
|
||||
} else {
|
||||
cachingFactory.createMediaSource(mediaItem)
|
||||
if (bypassCache) nonCachingFactory else cachingFactory
|
||||
}
|
||||
// Logs the three routing inputs directly rather than a re-derived label, so it can't drift
|
||||
// from shouldBypassCache.
|
||||
val source = factory.createMediaSource(mediaItem)
|
||||
|
||||
// Logs the routing inputs directly rather than a re-derived label, so it can't drift from
|
||||
// shouldBypassCache.
|
||||
Log.d(PLAYBACK_DIAG_TAG) {
|
||||
"SOURCE ${if (bypassCache) "BYPASS" else "CACHE"} flaggedLive=$flaggedLive hls=$hls knownOnDemand=$knownOnDemand " +
|
||||
"mime=${mediaItem.localConfiguration?.mimeType} -> ${source::class.java.simpleName} id=$id"
|
||||
|
||||
+6
-4
@@ -25,7 +25,6 @@ import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.HlsLivenessCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
@@ -66,7 +65,7 @@ internal fun livenessVerdictToRecord(
|
||||
* reliable live/on-demand discriminator (`#EXT-X-ENDLIST`) is inside the playlist, so it is knowable
|
||||
* only once ExoPlayer has loaded it — hence learning it here rather than from the URL.
|
||||
*
|
||||
* Only `.m3u8` items are considered; progressive media is unambiguous and never routed by liveness.
|
||||
* Only HLS items are considered; progressive media is unambiguous and never routed by liveness.
|
||||
*/
|
||||
class HlsLivenessRecorder(
|
||||
private val player: Player,
|
||||
@@ -85,8 +84,11 @@ class HlsLivenessRecorder(
|
||||
|
||||
private fun maybeRecord(allowOnDemand: Boolean) {
|
||||
if (player.currentTimeline.isEmpty) return
|
||||
val url = player.currentMediaItem?.mediaId ?: return
|
||||
if (!isLiveStreaming(url)) return
|
||||
val mediaItem = player.currentMediaItem ?: return
|
||||
// Must be the same predicate CustomMediaSourceFactory routes on — see isHlsMediaItem for
|
||||
// what goes wrong when the two disagree.
|
||||
if (!isHlsMediaItem(mediaItem)) return
|
||||
val url = mediaItem.mediaId
|
||||
|
||||
val known = HlsLivenessCache.verdict(url)
|
||||
val toRecord =
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.playerPool
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsPlaylist
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParserFactory
|
||||
import androidx.media3.exoplayer.upstream.ParsingLoadable
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Low-Latency HLS tags that we remove before media3's playlist parser sees them.
|
||||
*
|
||||
* `EXT-X-PART` and `EXT-X-PRELOAD-HINT` are the ones that matter: they are the only things in these
|
||||
* playlists that produce a **byte-range-bounded** chunk, which is what triggers the crash documented
|
||||
* on [LowLatencyStrippingHlsPlaylistParserFactory]. `EXT-X-PART-INF` and `EXT-X-SERVER-CONTROL` go
|
||||
* with them — leaving those behind advertises a low-latency contract (PART-TARGET, PART-HOLD-BACK,
|
||||
* CAN-BLOCK-RELOAD) that the stripped playlist can no longer honour.
|
||||
*
|
||||
* Deliberately **not** stripped:
|
||||
* - `EXT-X-SKIP` — marks a delta playlist whose segments were legitimately omitted. Removing the tag
|
||||
* while the segments stay missing would corrupt the playlist. Dropping `EXT-X-SERVER-CONTROL`
|
||||
* already stops media3 requesting deltas (`_HLS_skip=YES`), so this should never appear anyway.
|
||||
* - `EXT-X-RENDITION-REPORT` — inert once the parts are gone.
|
||||
*/
|
||||
private val LOW_LATENCY_TAGS =
|
||||
listOf(
|
||||
"#EXT-X-PART:",
|
||||
"#EXT-X-PART-INF:",
|
||||
"#EXT-X-PRELOAD-HINT:",
|
||||
"#EXT-X-SERVER-CONTROL:",
|
||||
)
|
||||
|
||||
/**
|
||||
* Removes the Low-Latency HLS tags from a playlist, leaving every other byte untouched.
|
||||
*
|
||||
* Line separators are preserved exactly: the split/join round-trips `\n`, keeps the `\r` of a CRLF
|
||||
* playlist as trailing content, and keeps a trailing newline (which `split` surfaces as a final
|
||||
* empty element). Blank lines are never dropped.
|
||||
*/
|
||||
internal fun stripLowLatencyTags(playlist: String): String {
|
||||
// Cheap pre-check: the overwhelming majority of playlists carry no LL tags at all, and this
|
||||
// runs on every playlist reload of every live stream.
|
||||
if (LOW_LATENCY_TAGS.none { playlist.contains(it) }) return playlist
|
||||
|
||||
return playlist
|
||||
.split("\n")
|
||||
.filterNot { line ->
|
||||
val trimmed = line.trimStart()
|
||||
LOW_LATENCY_TAGS.any { trimmed.startsWith(it) }
|
||||
}.joinToString("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* The byte-level form: decode as UTF-8, strip, re-encode.
|
||||
*
|
||||
* Split out from [LowLatencyStrippingParser] so the charset round-trip is reachable from a plain JVM
|
||||
* unit test — `parse` takes an `android.net.Uri`, which stubs to null under
|
||||
* `unitTests.isReturnDefaultValues`, so nothing that goes through it is testable without Robolectric.
|
||||
*
|
||||
* A UTF-8 BOM survives: decoding leaves U+FEFF in the string, `trimStart` does not treat it as
|
||||
* whitespace, and re-encoding reproduces the same three bytes. When there is nothing to strip the
|
||||
* *original array* is returned, so the common path neither re-encodes nor copies.
|
||||
*/
|
||||
internal fun stripLowLatencyTags(playlist: ByteArray): ByteArray {
|
||||
val original = playlist.toString(Charsets.UTF_8)
|
||||
val stripped = stripLowLatencyTags(original)
|
||||
return if (stripped === original) playlist else stripped.toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a media3 playlist parser and strips the Low-Latency tags before delegating.
|
||||
*
|
||||
* Playlists are a few KB, so reading the stream fully into memory is cheaper than the alternative of
|
||||
* a streaming line filter and keeps the transform a pure, testable [stripLowLatencyTags] call.
|
||||
*/
|
||||
@UnstableApi
|
||||
internal class LowLatencyStrippingParser(
|
||||
private val delegate: ParsingLoadable.Parser<HlsPlaylist>,
|
||||
) : ParsingLoadable.Parser<HlsPlaylist> {
|
||||
override fun parse(
|
||||
uri: Uri,
|
||||
inputStream: InputStream,
|
||||
): HlsPlaylist = delegate.parse(uri, ByteArrayInputStream(stripLowLatencyTags(inputStream.readBytes())))
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves media3 a de-low-latency-ed view of every HLS playlist.
|
||||
*
|
||||
* ## Why
|
||||
*
|
||||
* media3 crashes fatally on a Low-Latency HLS playlist whose parts are byte ranges — which is what
|
||||
* zap-stream-core emits (`#EXT-X-PART:URI="…",DURATION=…,BYTERANGE="359712@0"`). Reproduced on
|
||||
* media3 1.10.1, Pixel 9a / Android 17, ~0.6s after `ExoPlayerImpl.Init`:
|
||||
*
|
||||
* ```
|
||||
* IllegalArgumentException
|
||||
* at androidx.media3.datasource.DataSpec.<init> // checkArgument(length > 0 || length == LENGTH_UNSET)
|
||||
* at androidx.media3.datasource.DataSpec.subrange
|
||||
* at androidx.media3.exoplayer.hls.HlsMediaChunk.feedDataToExtractor
|
||||
* at androidx.media3.exoplayer.hls.HlsMediaChunk.loadMedia
|
||||
* ```
|
||||
*
|
||||
* `HlsMediaChunk.feedDataToExtractor` re-enters as `dataSpec.subrange(nextLoadPosition)`. Once the
|
||||
* whole bounded range has been fed to the extractor, `nextLoadPosition == length`, so `subrange`
|
||||
* asks for a zero-length `DataSpec` and the constructor's `length > 0` precondition throws. The
|
||||
* early-return guard in `subrange` only covers `offset == 0`, so a fully-consumed chunk falls
|
||||
* straight through. Unbounded chunks are safe — `length == C.LENGTH_UNSET` short-circuits — so this
|
||||
* is reachable only via a byte-range part.
|
||||
*
|
||||
* The failure is unrecoverable rather than merely retried: `Loader` wraps it as
|
||||
* `UnexpectedLoaderException`, which `DefaultLoadErrorHandlingPolicy` lists as non-retriable, so it
|
||||
* becomes a fatal `ExoPlaybackException: Source error`. Forcing a retry would not help either — the
|
||||
* `HlsMediaChunk` instance keeps its `nextLoadPosition`, so it would throw identically forever.
|
||||
*
|
||||
* Still present verbatim in media3 1.11.0-rc01, so there is no version to upgrade to.
|
||||
*
|
||||
* **Tracking: https://github.com/androidx/media/issues/3350** — delete this whole file and its test
|
||||
* once that is fixed and we are on a media3 release carrying the fix, then drop the explicit
|
||||
* `HlsMediaSource.Factory` in [CustomMediaSourceFactory] and let `DefaultMediaSourceFactory` build
|
||||
* HLS again. That also restores low latency, and removes the caveat about the wrapping
|
||||
* `DefaultMediaSourceFactory` features documented there.
|
||||
*
|
||||
* ## Trade-off
|
||||
*
|
||||
* We lose low latency on LL-HLS streams: playback falls back to whole segments, roughly one
|
||||
* `TARGETDURATION` further behind the live edge. LL playlists still list their complete segments
|
||||
* below the part tags, so they play normally otherwise. Given the alternative is a hard failure
|
||||
* within a second, and that media3 offers no per-stream way to decline just the parts, disabling it
|
||||
* globally is the conservative trade.
|
||||
*/
|
||||
@UnstableApi
|
||||
internal class LowLatencyStrippingHlsPlaylistParserFactory(
|
||||
private val delegate: HlsPlaylistParserFactory = DefaultHlsPlaylistParserFactory(),
|
||||
) : HlsPlaylistParserFactory {
|
||||
override fun createPlaylistParser(): ParsingLoadable.Parser<HlsPlaylist> = LowLatencyStrippingParser(delegate.createPlaylistParser())
|
||||
|
||||
override fun createPlaylistParser(
|
||||
multivariantPlaylist: HlsMultivariantPlaylist,
|
||||
previousMediaPlaylist: HlsMediaPlaylist?,
|
||||
): ParsingLoadable.Parser<HlsPlaylist> = LowLatencyStrippingParser(delegate.createPlaylistParser(multivariantPlaylist, previousMediaPlaylist))
|
||||
}
|
||||
+5
-2
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.playback.playerPool.positions
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.isHlsMediaItem
|
||||
import kotlin.math.abs
|
||||
|
||||
class CurrentPlayPositionCacher(
|
||||
@@ -41,7 +41,10 @@ class CurrentPlayPositionCacher(
|
||||
isLiveStreaming = false
|
||||
} else {
|
||||
currentUrl = mediaItem.mediaId
|
||||
isLiveStreaming = isLiveStreaming(mediaItem.mediaId)
|
||||
// Same predicate the source factory routes on, so a blossom-hosted playlist — which has
|
||||
// no `.m3u8` in its URL — is recognised here too and doesn't get a resume position
|
||||
// persisted against a stream that has no stable position to return to.
|
||||
isLiveStreaming = isHlsMediaItem(mediaItem)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -98,7 +98,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -431,7 +431,7 @@ private fun DialogContent(
|
||||
}
|
||||
|
||||
val isPdfOrStaticImage = myContent is MediaUrlImage || myContent is MediaLocalImage || myContent is MediaUrlPdf
|
||||
val isNotLiveStream = myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)
|
||||
val isNotLiveStream = myContent !is MediaUrlContent || !isHlsMedia(myContent.url, myContent.mimeType)
|
||||
if (isPdfOrStaticImage && isNotLiveStream) {
|
||||
val localContext = LocalContext.current
|
||||
|
||||
|
||||
@@ -63,8 +63,8 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.PictureIn
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
@@ -349,7 +349,7 @@ fun RenderTopButtonsForVoice(
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Row(modifier) {
|
||||
if (!isLiveStreaming(mediaData.videoUri)) {
|
||||
if (!isHlsMedia(mediaData.videoUri, mediaData.mimeType)) {
|
||||
AnimatedShareButton(controllerVisible) { popupExpanded, toggle ->
|
||||
ShareMediaAction(
|
||||
popupExpanded = popupExpanded,
|
||||
|
||||
+2
-2
@@ -51,7 +51,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVi
|
||||
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
|
||||
@@ -265,7 +265,7 @@ fun UrlImageView(
|
||||
content.toCoilModel(useLocalBlossomBridge)
|
||||
}
|
||||
val imageModelUrl = artworkUri ?: bridgedUrl
|
||||
val canLoadAsImage = !isVideo || artworkUri != null || !isLiveStreaming(content.url)
|
||||
val canLoadAsImage = !isVideo || artworkUri != null || !isHlsMedia(content.url, content.mimeType)
|
||||
|
||||
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
|
||||
if (it && canLoadAsImage) {
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The two cases that motivated replacing the old `.m3u8`-substring predicate are
|
||||
* [recognisesAnExtensionlessBlossomPlaylistByMime] and [ignoresM3u8InAQueryString]; the rest pin the
|
||||
* behaviour that must not regress while fixing them.
|
||||
*/
|
||||
class IsHlsMediaTest {
|
||||
@Test
|
||||
fun recognisesAnExtensionlessBlossomPlaylistByMime() {
|
||||
// BUD-10: the URL is a bare sha256 with no extension, so the mime is the only signal. The
|
||||
// old substring predicate answered false here and the item was routed to the disk cache —
|
||||
// fatal for a live playlist.
|
||||
val blossom = "https://blossom.example.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
|
||||
|
||||
assertTrue(isHlsMedia(blossom, "application/x-mpegurl"))
|
||||
assertTrue(isHlsMedia(blossom, "application/vnd.apple.mpegurl"))
|
||||
assertFalse("no mime and no extension leaves nothing to go on", isHlsMedia(blossom, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresM3u8InAQueryString() {
|
||||
// The other direction: progressive media permanently excluded from the cache because its
|
||||
// query string mentioned a playlist.
|
||||
assertFalse(isHlsMedia("https://host/video.mp4?ref=a.m3u8", null))
|
||||
assertFalse(isHlsMedia("https://host/video.mp4#a.m3u8", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recognisesAPlainM3u8Path() {
|
||||
assertTrue(isHlsMedia("https://host/live.m3u8", null))
|
||||
assertTrue(isHlsMedia("https://host/live.M3U8", null))
|
||||
assertTrue("query strings don't hide the path", isHlsMedia("https://host/live.m3u8?vt=abc", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anExplicitMimeWins() {
|
||||
// A non-HLS mime is respected even on an .m3u8 path — the mime is the more specific signal,
|
||||
// and toExoPlayerMimeType only consults the path when no mime was supplied.
|
||||
assertFalse(isHlsMedia("https://host/odd.m3u8", "video/mp4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun progressiveMediaIsNotHls() {
|
||||
assertFalse(isHlsMedia("https://host/video.mp4", null))
|
||||
assertFalse(isHlsMedia("https://host/video.mp4", "video/mp4"))
|
||||
assertFalse(isHlsMedia("https://host/audio.m4a", "audio/mp4"))
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.playerPool
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Verbatim excerpt of a zap-stream-core LL-HLS media playlist (api-uk.zap.stream, 2026-07-26) — the
|
||||
* playlist that crashes media3 1.10.1 in `HlsMediaChunk.feedDataToExtractor`. The byte-range parts
|
||||
* are what produce the bounded chunk behind that crash.
|
||||
*/
|
||||
private val ZAP_STREAM_LL_PLAYLIST =
|
||||
"""
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-PART-INF:PART-TARGET=0.49007290601730347
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:191184
|
||||
#EXT-X-MAP:URI="init.mp4"
|
||||
#EXT-X-SERVER-CONTROL:PART-HOLD-BACK=1.715,CAN-BLOCK-RELOAD=YES
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-07-26T20:18:32.611Z
|
||||
#EXTINF:1.961,
|
||||
191198.m4s
|
||||
#EXT-X-PART:URI="191199.m4s",DURATION=0.5009999999892898,INDEPENDENT=YES,BYTERANGE="359712@0"
|
||||
#EXT-X-PART:URI="191199.m4s",DURATION=0.5,BYTERANGE="153946@359712"
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-07-26T20:18:34.572Z
|
||||
#EXTINF:1.96,
|
||||
191199.m4s
|
||||
""".trimIndent()
|
||||
|
||||
class LowLatencyHlsStripperTest {
|
||||
@Test
|
||||
fun removesEveryLowLatencyTagFromARealPlaylist() {
|
||||
val result = stripLowLatencyTags(ZAP_STREAM_LL_PLAYLIST)
|
||||
|
||||
assertFalse("byte-range parts are the crash trigger", result.contains("#EXT-X-PART:"))
|
||||
assertFalse(result.contains("#EXT-X-PART-INF:"))
|
||||
assertFalse(result.contains("#EXT-X-SERVER-CONTROL:"))
|
||||
assertFalse("no BYTERANGE should survive", result.contains("BYTERANGE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsSegmentAndTagOrdering() {
|
||||
val result = stripLowLatencyTags(ZAP_STREAM_LL_PLAYLIST)
|
||||
|
||||
// Exact equality, so this also pins that everything a plain HLS player needs survives
|
||||
// untouched and in order: the header, MAP, PROGRAM-DATE-TIME, EXTINF and both segments.
|
||||
assertEquals(
|
||||
listOf(
|
||||
"#EXTM3U",
|
||||
"#EXT-X-VERSION:6",
|
||||
"#EXT-X-TARGETDURATION:2",
|
||||
"#EXT-X-MEDIA-SEQUENCE:191184",
|
||||
"""#EXT-X-MAP:URI="init.mp4"""",
|
||||
"#EXT-X-PROGRAM-DATE-TIME:2026-07-26T20:18:32.611Z",
|
||||
"#EXTINF:1.961,",
|
||||
"191198.m4s",
|
||||
"#EXT-X-PROGRAM-DATE-TIME:2026-07-26T20:18:34.572Z",
|
||||
"#EXTINF:1.96,",
|
||||
"191199.m4s",
|
||||
),
|
||||
result.split("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesAPlainPlaylistByteIdentical() {
|
||||
// The common case, on every playlist reload of every live stream: nothing to do.
|
||||
val plain =
|
||||
"""
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:4230
|
||||
#EXT-X-MAP:URI="init.mp4"
|
||||
#EXTINF:2,
|
||||
4230.m4s
|
||||
""".trimIndent()
|
||||
|
||||
assertSame("unchanged playlists should not be rebuilt", plain, stripLowLatencyTags(plain))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesTrailingNewlineAndBlankLines() {
|
||||
val input = "#EXTM3U\n#EXT-X-PART:URI=\"a.m4s\",BYTERANGE=\"1@0\"\n\n#EXTINF:2,\na.m4s\n"
|
||||
|
||||
assertEquals("#EXTM3U\n\n#EXTINF:2,\na.m4s\n", stripLowLatencyTags(input))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesCrLfLineEndings() {
|
||||
val input = "#EXTM3U\r\n#EXT-X-PART:URI=\"a.m4s\",BYTERANGE=\"1@0\"\r\n#EXTINF:2,\r\na.m4s\r\n"
|
||||
|
||||
assertEquals("#EXTM3U\r\n#EXTINF:2,\r\na.m4s\r\n", stripLowLatencyTags(input))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsDeltaPlaylistAndRenditionReportTags() {
|
||||
// EXT-X-SKIP marks legitimately omitted segments; removing it would corrupt the playlist.
|
||||
// EXT-X-RENDITION-REPORT is inert once the parts are gone.
|
||||
val input =
|
||||
"""
|
||||
#EXTM3U
|
||||
#EXT-X-SKIP:SKIPPED-SEGMENTS=10
|
||||
#EXT-X-PART:URI="a.m4s",BYTERANGE="1@0"
|
||||
#EXT-X-RENDITION-REPORT:URI="../b/live.m3u8",LAST-MSN=42
|
||||
""".trimIndent()
|
||||
|
||||
val result = stripLowLatencyTags(input)
|
||||
|
||||
assertTrue(result.contains("#EXT-X-SKIP:SKIPPED-SEGMENTS=10"))
|
||||
assertTrue(result.contains("#EXT-X-RENDITION-REPORT:"))
|
||||
assertFalse(result.contains("#EXT-X-PART:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripsPreloadHint() {
|
||||
// The *other* tag that yields a byte-range chunk, so it matters as much as EXT-X-PART.
|
||||
// Synthetic rather than folded into the capture above, which is labelled verbatim and did
|
||||
// not carry a hint; shaped per RFC 8216 §4.4.5.3.
|
||||
val input =
|
||||
"""
|
||||
#EXTM3U
|
||||
#EXTINF:1.96,
|
||||
191199.m4s
|
||||
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="191200.m4s",BYTERANGE-START=402501
|
||||
""".trimIndent()
|
||||
|
||||
val result = stripLowLatencyTags(input)
|
||||
|
||||
assertFalse(result.contains("#EXT-X-PRELOAD-HINT"))
|
||||
assertFalse("no byte range should survive", result.contains("BYTERANGE-START"))
|
||||
assertEquals("#EXTM3U\n#EXTINF:1.96,\n191199.m4s", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun byteFormPreservesBomAndMultibyteWhenStripping() {
|
||||
// Written as raw bytes rather than a "" literal so the fixture states the on-the-wire
|
||||
// encoding directly. 🎵 is a 4-byte sequence / surrogate pair, so it also covers the
|
||||
// decode-modify-re-encode round trip beyond the BMP.
|
||||
val bom = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte())
|
||||
val input =
|
||||
bom +
|
||||
(
|
||||
"#EXTM3U\n" +
|
||||
"#EXT-X-PART:URI=\"a.m4s\",BYTERANGE=\"1@0\"\n" +
|
||||
"#EXTINF:2,caffè 🎵\n" +
|
||||
"a.m4s\n"
|
||||
).toByteArray(Charsets.UTF_8)
|
||||
|
||||
val result = stripLowLatencyTags(input)
|
||||
|
||||
assertArrayEquals(
|
||||
bom + "#EXTM3U\n#EXTINF:2,caffè 🎵\na.m4s\n".toByteArray(Charsets.UTF_8),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun byteFormForwardsTheOriginalArrayWhenNothingToStrip() {
|
||||
val input = "#EXTM3U\n#EXTINF:2,\na.m4s\n".toByteArray(Charsets.UTF_8)
|
||||
|
||||
// Same array, not an equal copy: the common path must not re-encode.
|
||||
assertSame(input, stripLowLatencyTags(input))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotMatchTagsBySubstring() {
|
||||
// EXT-X-PARTY-TIME is fictional, but the point is that the match must be anchored: a bare
|
||||
// `contains("#EXT-X-PART")` without the colon would eat unrelated tags.
|
||||
val input = "#EXTM3U\n#EXT-X-PARTY-TIME:1\n#EXT-X-PART:URI=\"a.m4s\""
|
||||
|
||||
val result = stripLowLatencyTags(input)
|
||||
|
||||
assertTrue(result.contains("#EXT-X-PARTY-TIME:1"))
|
||||
assertFalse(result.contains("#EXT-X-PART:"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user