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:
Vitor Pamplona
2026-07-29 08:30:39 -04:00
committed by GitHub
13 changed files with 576 additions and 54 deletions
@@ -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
@@ -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)
@@ -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
@@ -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)
@@ -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"
@@ -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 =
@@ -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))
}
@@ -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)
}
}
@@ -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,
@@ -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) {
@@ -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"))
}
}
@@ -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:"))
}
}