Skip to content

Latest commit

 

History

History
447 lines (431 loc) · 35.7 KB

media-playback.md

File metadata and controls

447 lines (431 loc) · 35.7 KB

Media playback

One section of Twig's hard-won lessons. The index, the ship checklist and the shared conventions are in AGENTS.md; the other sections live beside this file in docs/lessons/.

  • A UHD MKV played sound with no picture (2026-11-04): the reported remux's video track was video/dolby-vision, dvhe.07.06, supported=false, while both TrueHD and AC-3 audio were supported. Media3 1.9 deliberately excludes Profile 7 from its automatic HEVC fallback. DolbyVisionFallbackExtractorsFactory checks the format before track selection: only unencrypted Profile 7 with BT.2020/PQ metadata (from the container or base-layer SPS), a readable base-layer SPS, no supporting native Dolby Vision decoder, and a supporting HEVC decoder is relabelled as HEVC. Derive the HEVC codec string from that SPS; Dolby Vision's profile/level numbers cannot be reused. Preserve initialization data, HDR metadata, samples, timestamps, audio and subtitles. MatroskaExtractor does not copy HEVC SPS colour information into its Format; the reported file has no container Colour element, so fill missing colour and bit-depth fields from the SPS rather than requiring container metadata. Never apply this to Profile 5, which has no HDR10-compatible base layer. DolbyVisionFallbackTest includes the reported remux's container header (no media samples) and covers native playback, unsupported HEVC, malformed/missing initialization data, both extractor factory entry points and sample/seek forwarding.

  • A network interruption left video permanently idle (2026-11-02): the error listener displayed ERROR_CODE_IO_UNSPECIFIED but only decoder failures had a recovery path; the Play button called play() without preparing a new source. Network recovery now opens a fresh dedicated random reader at the saved position. Automatic reconnects are capped at five, delayed by 1/2/4/8/15 seconds; offline waiting does not spend attempts, and recovery triggers an immediate attempt. Only 30 seconds of uninterrupted playback resets the budget. Pause, focus loss, leaving and episode changes cancel pending work; manual Play after failure starts a fresh budget. Connection setup/first data has a 10-second timeout, which ends after a successful read rather than timing out a legitimate long container probe or M2TS seek. Bounded workers close late results after cancellation. The video source disables media3's additional loader retries, and an unexpected early EOF raises an IO error instead of reporting a successfully completed video. Permanent IO failures and parser/decoder failures do not enter the network retry loop. PlaybackRetryPolicyTest, PlaybackConnectionTest and PlayerNetworkRetryTest cover exhaustion, network transitions, manual retry, cancellation, timeout and source ownership.

  • Playlist actions silently failed before background metadata reached their row (2026-10-29). After importing many songs, Track info and Share returned immediately when the activity's resolved-file cache had no entry; Locate was omitted altogether. The apparent dependency on tags was really a dependency on a single worker that resolves one file, reads its tags, then advances to the next file. Resolve a selected track independently for direct actions, and derive Locate availability from its saved source identity. Do not queue these actions behind metadata backfill. Track info opens immediately, shows listing information before the media probe, and updates only the original still-open dialog. Closing it must not allow a late result to reopen it. PlaylistMenuLoadingTest blocks the entire backfill executor while exercising info, sharing and locating, including missing sources and dismissal during a slow read.

  • Imported tracks disappeared from the live queue during a playlist switch (2026-10-29 review): append captured existing track IDs while a replacement queue was still loading. Those IDs belonged to the previous queue, so a newly imported track shared with that queue was excluded before the new queue replaced it. Skip that early deduplication while loading, then deduplicate against the installed target queue in the main-thread callback. Keep generation and playlist checks so exit or another switch cannot revive stale work. PlaylistImportQueueTest holds the queue worker while switching and importing a track from the previous queue.

  • Playback time missing from some system notification headers (2026-10-26, issue #23): the media seek bar's time labels come from MediaSession; a header stopwatch is a separate native Notification chronometer. Set when to wall-clock time minus player position and enable showWhen/usesChronometer only while playing. Pause/buffering hides it, and resume rebases it; SystemUI owns ticking and decides whether to show it. Do not rewrite titles, poll each second, or add custom RemoteViews. MusicEngine forwards position discontinuities so seeks from the player UI, previous-track restart and system controls immediately refresh both session position and timer. MusicNotificationTimeTest covers native extras, pause/resume, seek/track reset, absence of periodic notification updates and the real ExoPlayer seek event on API 24, 30 and 34.

  • Music volume swipes opened a laggy system panel (2026-10-25, issue #23): cover swipes used FLAG_SHOW_UI and queried AudioManager on each movement. Match the video player's in-player percentage overlay and use volume flags=0. Snapshot the range/current volume at touch-down; write only when the target step changes. Keep feedback visible during the swipe and dismiss 300 ms after release/cancel; a new gesture cancels the previous timeout. Both music layouts provide the same overlay. MusicVolumeGestureTest checks flags, repeated calls, hint timing and landscape, alongside tap/lyrics/horizontal-swipe behavior.

  • Quitting Twig left the music notification behind (2026-10-25): the app exit path released MusicEngine but did not stop MusicService, unlike the music player's exit. Route both through MusicUi.exit, which also closes music screens. Service destruction explicitly removes the foreground notification and rejects late notification updates from cover-loading callbacks. The notification's Exit button follows Like in both legacy notification actions and MediaSession custom actions; temporary tracks omit Like but retain Exit. Exit uses MusicUi.exit and suppresses pending updates before releasing playback, then removes the notification immediately. Standard transport Stop keeps its existing pause semantics. MusicExitTest / MusicNotificationExitTest cover player release, screen/service exit, both action paths and notification removal after delayed updates.

  • History appeared unresponsive before opening the video player (2026-10-24): connecting and statting the file in PaneFragment delayed activity launch. Start the player immediately with the history title/path, resolve the connection and real metadata on IO inside the activity, and hold its surface behind the resume gate. Do not derive resume keys from the placeholder file: load progress only after stat supplies the real name and size. Errors appear in the opened player; leaving cancels the pending load. HistoryPlayerLoadingTest covers blocked metadata, resume/from-start, failure and exit.

  • Fast lyric swipes could seek or toggle the cover (2026-10-16): classify both MOVE and UP with SwipeGesture before deciding to tap. LyricsSwipeTest covers releases without MOVE, vertical drags and cancellation.

  • Properties spun forever on an MKV with a zero-filled Cues index (2026-10-15): track retrieval timed out without releasing preparation, then system metadata parsing could block indefinitely. MediaProbe shares a 16 MiB / 10 s budget across both parsers; system calls run on at most two isolated workers. Properties skip MKV Cues, retain already-read tracks on failure, and close media3 retrieval on every exit. Playback is unaffected. MediaProbeTest covers the limits and a generated MKV with corrupt Cues.

  • AVI playback + thumbnails (★ traced and settled 2026-07-22, media3 1.3.1 → 1.9.0):

    • Internal media source URIs must carry a real extension: non-local backends used to get a fake URI with no path (e.g. "twig://media", where media parses as an authority, not a path). With no recognizable extension, DefaultExtractorsFactory degrades to sniff()ing extractors in a fixed order — and in that order MP3 comes before AVI, so if an AVI's audio track happens to be matched by Mp3Extractor (common with old XVID+MP3 AVIs), the whole container parse collapses into a single audio track with a duration of a few hundred milliseconds. Changing it to "twig:///media.$ext" (note the three slashes, so the extension lands in the path) fixes it.
    • media3 1.3.1's Mp3Extractor.synchronize has a known bug with CBR MP3 (fixed in 1.4.1, upstream commit b09cea9; the root cause is ConstantBitrateSeeker not knowing the end of the file and scanning forward forever for a sync word), showing up as ParserException: Searched too many bytes. Upgrading to 1.9.0 fixes it (the jellyfin-media3-ffmpeg-decoder version must be bumped in lockstep, and newer media3 requires compileSdk ≥ 35, which is why compileSdk went to 36).
    • A decoder crashing at runtime ≠ "no decoder": on one test device c2.android.mp3.decoder crashed natively on start (err 0xe/14, then released by the system). DefaultRenderersFactory's extension fallback (EXTENSION_RENDERER_MODE_ON) only falls through to ffmpeg when the platform declares no supporting decoder, and can do nothing about "declares support but explodes on use". The only fix is to recover in onPlayerError, in two steps: first switch the whole thing to EXTENSION_RENDERER_MODE_PREFER, rebuild the player and retry once; if the source's audio track itself is partially corrupt (bad samples stuffed with garbage that ffmpeg also cannot decode, showing up as playback stalling and triggering ERROR_CODE_TIMEOUT), drop one more level and just setTrackTypeDisabled(TRACK_TYPE_AUDIO, true) to play the video silently. Both steps need a full player rebuild — in the error state the renderer is already disabled and cannot be swapped in place.
    • AVI thumbnails cannot go through MediaMetadataRetriever: the system MMR does not demux AVI, locally or over the network, so it was always a black image. Once playback was fixed we confirmed media3's AviExtractor decodes frames fine, so GlFrameGrabber bypasses MMR: start an invisible ExoPlayer decoding into a SurfaceTexture and write a minimal EGL/GL pipeline that draws the external OES texture into an ordinary 2D texture FBO and glReadPixels it. You cannot just attach an ImageReader: hardware video decoders emit device-specific opaque/YUV buffers, and reading them directly without sampling through GL gives corrupted output or format errors on many vendors' devices (this is also why the official FrameExtractor builds a whole media3-effect GL pipeline; not wanting that dependency's size is why this minimal path is hand-written). Do not take the video size from onVideoSizeChanged to create the Surface — with no output Surface, MediaCodecVideoRenderer never really starts the decode pipeline and that callback never fires, a chicken-and-egg deadlock; read the Format's width/height in onTracksChanged instead, which does not depend on the pipeline running. The duration is only known after the container header (and possibly idx1) is parsed, so unlike MP4/MKV the target time cannot be computed up front — instead, in onEvents, issue an extra seekTo as soon as the duration is known. For network sources, seeking to idx1 (usually at the end of the file) causes a flurry of small round trips, so reuse the player's own BufferedRandomSource read-ahead cache; a bare RandomSource over the network burns through the generation timeout easily.
    • MP3 audio in an AVI is a byte stream, not frames (★ traced and settled 2026-09-04, AviMp3Reframe.kt): strh.sampleSize is 1 and dwLength counts bytes, so the muxer cuts the 01wb chunks wherever it likes. A LAME/VirtualDub-era file interleaves one ~534-byte slice per video frame, and 534 is not a multiple of the 384-byte frame of a 128kbps/48kHz stream — in test2.avi only 9 of the first 4190 frames start on a chunk boundary. media3's AviExtractor forwards each chunk as one sample, and an MP3 decoder handed a buffer that does not begin with a frame header decodes nothing.
      • The symptom is not "no sound": the platform decoder answers err 0xe/14 (C2SoftMp3Dec: Error in parse mp3 header), MediaPlayerActivity's fallback rebuilds with the ffmpeg renderer, the audio clock then anchors on a garbage timestamp — a file opened at 0 reports state=3 pos=14091 — and never advances, so media3 kills playback 10 s later with Player stuck playing with no progress. Reported as "it opens at 13 seconds and then stops; I can seek but it stays stuck", and it hits roughly half of all AVIs — the other half happen to be muxed one frame per chunk.
      • ★ Do not diagnose this from the video side. This file is XVID Advanced Simple Profile with B-frames (IPBBPBB…) and the device's only video/mp4v-es decoder, c2.android.mpeg4.decoder, advertises profile/levels: [1/256 (Simple/6)] — which looks exactly like the culprit and is not: a video-only remux of the same clip plays fine. The way to settle it is three clips, not reasoning: video only, ffmpeg -c copy remux (its AVI muxer writes one 384-byte frame per chunk, so the audio comes out aligned), and a byte-exact truncation of the original. Only the third one reproduces.
      • ★ There is no ffmpeg video fallback to reach for either, whatever EXTENSION_RENDERER_MODE_PREFER suggests. DefaultRenderersFactory does load ExperimentalFfmpegVideoRenderer reflectively and logs Loaded FfmpegVideoRenderer, but in the shipped extension its supportsFormat returns FORMAT_UNSUPPORTED_TYPE and its createDecoder returns null; libffmpegJNI.so exports audio entry points only. The log line means the class is present, not that anything can decode.
      • The fix is what every other demuxer does: buffer the audio chunks as one continuous byte stream, cut it back into MP3 frames and emit one sample per frame (Mp3FrameTrackOutput, installed by wrapping DefaultExtractorsFactory in AviReframingExtractorsFactory — only AviExtractor is wrapped, every other container is untouched). It runs for every MP3-in-AVI, aligned or not: an already-aligned file comes out byte-identical, so there is no second code path to keep working.
      • ★ Timestamps come from the frames, not from the chunks. ChunkReader spreads the duration evenly over the chunk count, which is only true when the chunks are uniform — this file opens with an 8000-byte audio preload followed by 534-byte chunks, so from the second chunk on the container's timeline is 0.47 s ahead of the audio actually stored there. Anchor once (at the first chunk after a seek) and let the frame durations carry the timeline; that is byte-derived, and it is what ffmpeg does for AVI audio with sampleSize == 1.
    • ★ A false MP3 sync word in leading 0xFF padding turns into "the whole file plays at 2/3 speed": more than one muxer opens the audio stream with a run of 0xFF, and where that run meets real data it parses as a perfectly valid frame header. Believing the first plausible header emitted two junk samples (288 and 128 bytes) ahead of the real audio and skewed the timeline by 20ms; the audio sink answered with Unexpected audio track timestamp discontinuity about twice a second, and each one is a hiccup — a 15 s clip took 22.5 s to play. The fix is the standard double-sync confirmation: believe a candidate only once a second matching header turns up exactly one frame later, then stay locked while frames follow each other. ★ The symptom reads as a video problem ("it stutters"), and it is not: the clock is the audio clock, so anything that stalls the audio drags the picture with it. Split the file with ffmpeg -map 0:a / -map 0:v and time each half against its own duration — the audio-only half took 22.5 s for 15 s of content while the video-only half was exact, which pointed straight at the reframer. Remuxing the same audio into MKV (-c copy) and getting 15 s confirmed the data was fine and the AVI path was not.
    • AVI has no presentation timestamps, and MPEG-4 arrives in three shapes that all need repairing (★ traced and settled 2026-09-04, Mpeg4VopTrackOutput):
      • Frame order. MPEG-4 ASP stores frames in coding order and displays them in another: I0 P3 B1 B2 P6 B4 B5 (subscript = display slot). Every other container records the display time per sample; AVI records nothing, so AviExtractor numbers the chunks as it meets them and labels P3 as frame 1. The picture jumps three frames forward and two back — "the image keeps shaking".
      • Packed bitstream. DivX/XVID-era encoders pack a P-VOP and the B-VOP after it into one chunk, then emit a 7-byte not-coded VOP as its own chunk to keep the chunk count equal to the frame count. A decoder handed the packed chunk decodes the first VOP and reports decoded frame, ignoring further trailing bytes for the rest, so a third of the frames never appear. ★ You will not see this by scanning the first few KB of each chunk — the second VOP is thousands of bytes in, so a scan that caps its read length reports one VOP per chunk and the file looks ordinary.
      • Stuffing chunks. Besides the 7-byte not-coded VOP there are 1-byte 7f chunks holding no VOP at all ("repeat the last frame"). Fed to c2.android.mpeg4.decoder they answer failed to decode vop header, the codec goes to the error state and the file is reported undecodable — which is what "this one never played and never made a thumbnail" was.
      • The repair works on VOPs, not chunks: a group is one anchor (I/P/S) plus the B-VOPs after it, and it owns the timestamps ("slots") of every chunk it spans, stuffing included — which is exactly where a packed chunk's second VOP gets its slot. The display times are that group's slots rotated by one. Telling a stuffing VOP from a real one means actually parsing vop_coded, which sits behind a variable-width vop_time_increment whose width comes from vop_time_increment_resolution in the VOL header — so the VOL is parsed once and kept (Mpeg4Headers).
      • ★ The first VOP of a chunk must keep whatever precedes it. A keyframe chunk carries the VOS/VO/VOL headers in front of the VOP, and that is the only place the decoder ever looks for them: slice the VOP out on its own start code and every file answers PVInitVideoDecoder failed. Unsupported content? before a frame is decoded.
      • ★ c2.android.mpeg4.decoder advertises profile/levels: [1/256 (Simple/6)] and decodes B-VOPs perfectly well anyway. We read that as "this device cannot do Advanced Simple Profile", built a whole frame-dropping fallback on it, and shipped a third of the frame rate for nothing; every one of these files plays at full rate once the samples reach the decoder in one piece. The advertised profile list is not a statement about what a decoder will accept.
      • ★ media3's ffmpeg extension is no video fallback, whatever logcat implies. DefaultRenderersFactory loads ExperimentalFfmpegVideoRenderer reflectively and logs Loaded FfmpegVideoRenderer, but in the shipped extension its supportsFormat returns FORMAT_UNSUPPORTED_TYPE and createDecoder returns null, and libffmpegJNI.so exports audio entry points only. The log line means the class is present, not that anything can decode.
      • ★ How to settle any of this: time it, do not look at it. Eyeball verdicts on "does it shake" flip-flopped across three rounds here and sent the whole investigation down a blind alley. player: state=3 to player: ended against the file's own duration is a number, and driving playback with am start -a android.intent.action.VIEW -d file:///... -n com.twig.app/.ui.ViewIntentActivity plus logcat needs nobody watching the screen. Better still, run the real AviExtractor through the repair in a Robolectric test over the real file and print the emitted (timeUs, size) list — that is what finally showed the junk 288/128-byte audio samples and the packed-bitstream frame loss.
      • ★ "Another app plays it fine" is a real data point — find out how before dismissing it. X-plore plays all of these smoothly, and its logcat shows no video decoder being created, which we read as "so there is no picture". Wrong: it does not go through MediaCodec at all, it carries a software MPEG-4 decoder of its own (on the order of 80 KB of native code), so there is nothing for the platform to log. Twig needs no such thing — every one of these files plays at full frame rate through the platform decoder once its samples arrive intact — but that is roughly the price if it ever did. ★ The way to check "is there a picture at all" is not to reason about logs: record the screen (adb shell screenrecord) and diff consecutive frames. Eight seconds settled a question three rounds of argument had not.
  • Real M2TS (Blu-ray BDAV) playback (★ traced and settled 2026-07-22, M2tsStrippingDataSource / PgsTsReader):

    • Packets are 192 bytes, not 188: BDAV prefixes every TS packet with a 4-byte timestamp, while media3's TsExtractor hard-codes a 188-byte stride when looking for the sync byte (0x47) and simply reports "unrecognized container". Even the first packet carries the prefix (the sync byte is at offset 4, not 0) — we got that wrong once, so do not assume the stream starts with a sync byte at byte 0. Some tools also store plain 188-byte TS streams with an .m2ts extension, so the real packet size must be probed (is the sync byte consistently aligned on a 188 or 192 stride?) before deciding to strip; never assume from the extension. When stripping, also use ProgressiveMediaSource.Factory's two-argument constructor to name TsExtractor explicitly instead of relying on DefaultExtractorsFactory's sniff order.
    • HDMV private stream_types collide with the standard registry: Blu-ray/HDMV reuses a few byte values with meanings entirely different from the ATSC/DVB registry, and media3 only knows the standard ones:
      • 0x86 officially means SCTE-35 splice information; HDMV reuses it for DTS-HD Master Audio — untreated, the highest-quality DTS-X/DTS-HD MA main track is swallowed as SCTE-35 and no audio track is ever created. This app only plays local files and has no use for real SCTE-35, so treating it as DTS-HD matches the use case.
      • 0x83 is TrueHD/Atmos, and media3 has no TrueHD/MLP ElementaryStreamReader anywhere in extractor.ts (not a missing case — it was never implemented), so untreated there is no sound at all and no selectable audio track. The Blu-ray spec requires every TrueHD stream to embed a backward-compatible AC-3 core (5.1) — we use Ac3Reader to scan for sync words, skip the interleaved MLP frames and decode only the real AC-3 core frames, giving up Atmos immersive channels and TrueHD lossless in exchange for audio.
      • 0x90 is HDMV PGS (bitmap subtitles), for which media3 likewise has no TS-layer sample splitter (it only implements the different DVB subtitle standard). PGS bitmap decoding itself exists in media3 (PgsParser, used by MKV); what is missing is "how to cut PES packets into the samples it expects". The new PgsTsReader: one PES packet holds exactly one complete PGS segment, timestamps come from the PES packet's own PTS (unlike a standalone .sup file where each segment is preceded by a redundant 'PG' magic + PTS + DTS — that is .sup's own invention for living outside a container); accumulate PES packets until segment_type = END (0x80) and emit the whole run as one sample, matching the granularity of one MKV block per Display Set.
      • All three custom stream_types delegate to DefaultTsPayloadReaderFactory (with FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS) through BluRayTsPayloadReaderFactory; nothing is rewritten wholesale.
      • Before deciding whether a stream_type is covered by an official descriptor, read readEsInfo in TsExtractor's source — it only lets a registration_descriptor override the raw PMT stream_type when that type is 0x05/0x06 (generic placeholders), so concrete values like 0x83 are unaffected and there is no conflict with that logic.
    • TsExtractor's newer API changed: the old FLAG_EMIT_RAW_SUBTITLE_DATA + SubtitleParser.Factory.UNSUPPORTED combination now throws IllegalStateException: Legacy decoding is disabled in the newer TextRenderer — the whole "raw samples + legacy SubtitleDecoder" path is gone. You must omit that flag and pass a real DefaultSubtitleParserFactory, converting samples to application/x-media3-cues during extraction.
    • media3's built-in readers have no defense against malformed input and can take the whole player down: we hit SpliceInfoDecoder (malformed SCTE-35 going out of bounds, already avoided by disabling the metadata track) and Ac3Util.parseAc3SyncframeInfo (one file's AC3 frame header had a frmsizecod outside the standard range → array index out of bounds → ERROR_CODE_IO_UNSPECIFIED, thrown on the extraction thread, not the decode thread, so disabling the audio track does not help). SafeTsPayloadReader adds a defensive try-catch, applied uniformly at BluRayTsPayloadReaderFactory's exit (covering both our own readers and those delegated to the built-in factory): swallow the exception, drop that whole packet, call seek() to reset internal state and carry on.
    • Never allocate in a hand-written DataSource's read() hot path: M2tsStrippingDataSource allocated a ByteArray(4) for every 4-byte prefix it skipped, and at volume (seek probing, large sequential reads) the allocation rate blew up the GC — measured as a full minute of "Waiting for a blocking GC", which was the real root cause of "m2ts seeks slowly, thumbnails time out", entirely unrelated to network speed. Reusing a member field fixed it. While there, read() was changed to loop internally and fill as much of the caller's requested length as possible (with 192-byte packets, without this it can only return 188 bytes per call, forcing hundreds of consecutive calls).
    • Seeking in extremely high-bitrate huge files (30 GB+, ~30 Mbps) is still slow: after the GC problem was fixed we confirmed the seek lands only once (no repeated binary-search re-entry), but it still takes tens of seconds and a lot of reading to render the first frame. DefaultLoadControl only needs 2 s of buffer (bufferForPlaybackAfterRebufferMs), so the buffering policy is not to blame. The real cost is in media3's own TsBinarySearchSeeker finding a renderable keyframe after landing — TS has no precise index like MP4/MKV, so it can only interpolate PCR timestamps and bisect. Fixing it would mean forking that logic; the payoff for such extreme files was judged limited and it is not done.
    • How to verify: to check what a stream_type/descriptor really is, do not use ffprobe's high-level codec_tag_string (it maps the result to a human-readable name and hides the raw stream_type value — the string "AC-3" misled us once). Write your own Python that parses PAT → PMT → the ES loop and read the raw stream_type byte and the descriptors (especially the format_identifier of the tag=0x05 registration_descriptor); that is ground truth. To de-obfuscate a player crash stack, do not look up line numbers in mapping.txt by hand (you will hit the illusion of different classes sharing the same obfuscated short name — it looked like AES256Options/OpenJSSEPlatform, entirely unrelated classes, simply because a real retrace was never run). Use the SDK's "$ANDROID_HOME"/tools/proguard/bin/retrace.sh with app/build/outputs/mapping/release/mapping.txt to resolve properly at method level.
  • Episode auto-play (★ 2026-08-19, Episodes + EpisodeSeries): play the next episode automatically when one ends, plus previous/next-episode buttons on the control bar (shown only when a queue can be derived). The queue takes two paths — ask the backend first, guess from file names second:

    • The backend knows the series structure (EpisodeSeries, currently only Jellyfin/Emby): /Shows/{seriesId}/Episodes?userId= returns everything at once (across seasons, already sorted), identical on both servers. ★ For an episode reached from "Continue watching" this is the only workable path — its siblings in the tree are other shows, so looking at the same directory can never build a queue; it must resolve through the item's own SeriesId.
    • Other backends: list the video files in the same directory and group them by the season/episode numbers in the file name (Episodes.queueOf).
    • ★ In file-name parsing, match order is priority, and it is the only thing that matters: episode file names are usually trailed by a string of quality/codec tags full of numbers — …S01E01.The.Hedge.Knight.2160p.HBOMax.WEB-DL.DDP.5.1.Atmos.DV.HDR.H.265.mkv contains 2160, 5, 1 and 265, any of which could be mistaken for an episode number. So SxxExx returns immediately on a match and nothing after it is even looked at; E01/EP01 comes next; "bare number" comes last and is the strictest (the number must be the last segment of the name, which is why Show 1080p BluRay.mkv is rejected).
    • Fewer than two episodes means no queue (the buttons stay hidden): real directories are full of things like Holiday photos 01.mp4, and it is better to offer no button than to string a pile of unrelated videos into a "series".
    • Number-only videos need an explicit filename case (01.mp4, (1).mp4, (1).mp4): the ordinary bare-number parser rejects an empty show prefix. Group these files only with other number-only videos in the same directory, require at least two, and sort numerically. Scope remembered track/subtitle choices to that directory too; otherwise every 01.mp4 on one backend would share the same empty-prefix series key.
    • Switching episodes does not recreate the Activity: the surface is still there, and recreating flashes black and re-runs immersive mode / gestures / orientation lock. ★ But once the surface exists there will be no further surfaceCreated callback, so the holder must be handed to the resume gate manually (pendingHolder + resumeReady) so the new episode takes the same path as the first playback — otherwise it waits behind the gate forever and never starts.
    • Finishing an episode must report PlayState.STOP: the server ends the "now playing" session on it; without it that session hangs around and collides with the next episode's START.
  • The player quits mid-playback and the app then asks for the master password again (★ traced and settled 2026-08-31, media3 1.9.0): symptom was reported as two separate problems — "4K HDR playback exits after a while" and "and then I have to unlock again" — but it is one event. The device's crash buffer had three com.twig.app records, all java.lang.OutOfMemoryError … target footprint 268435456, growth limit 268435456 on the ExoPlayer:Playback thread (plus one native SIGABRT in MediaCodec_loop whose abort message was could not create MediaCodec.BufferInfo object — the same starved heap, just from the JNI side). The unlock prompt is not a bug: Secrets keeps the DEK in a @Volatile field, so a dead process means locked() is true again and SecurityUi.gate does exactly what it should. Diagnosing this from the unlock symptom alone leads nowhere; go read logcat -b crash first.

    • Root cause: MediaPlayerActivity built its ExoPlayer with no LoadControl, taking media3's defaults — DEFAULT_VIDEO_BUFFER_SIZE is 125MB and DEFAULT_MUXED_BUFFER_SIZE 137MB, against a dalvik.vm.heapgrowthlimit of 256MB. Add BufferedRandomSource's own 24MB block cache and a 60fps HDR10 remux fills the heap in well under a minute.
    • ★ media3's small local-playback tier does not save us: 1.9 added DEFAULT_VIDEO_BUFFER_SIZE_FOR_LOCAL_PLAYBACK (18.7MB), but it is selected by URI scheme against LOCAL_PLAYBACK_SCHEMES = file/content/data/android.resource/ rawresource/asset. Network playback goes through twig:///media.$ext (the URI has to carry a real extension, see the AVI entry above), which matches none of them, so every network video lands in the largest tier. This is why the same file plays fine from local storage and dies over SMB — the difference is the scheme, not the transport.
    • Fix: an explicit DefaultLoadControl with targetBufferBytes = 48MB and 15s/30s duration bounds. The byte cap is what governs high-bitrate files; the duration bounds are what governs everything else.
    • largeHeap was deliberately rejected: the device's dalvik.vm.heapsize is 512MB so it would have worked, but it only doubles the ceiling the buffer then grows into — it treats the symptom, and it is against the project's size-first line.
    • GlFrameGrabber builds its own ExoPlayer and still takes the defaults. It grabs one frame and releases, so the buffer has no time to fill; left alone on purpose, but it is the same trap if it ever starts decoding longer.
  • Mounted music queues are temporary (2026-10-09): archive and restic entries previously fell back to single-file playback. They now share the music queue, using the current mount's scheme and the listing's size/name metadata. Persisting that scheme would leave unusable tracks after process death; reconstructing the mount would require archive host, repository, snapshot and unlock handling. Keep these tracks in Playlist.TEMP in memory, exclude them from favorites, saved playlists and M3U export, and clear them on player exit. Keep the last durable queue intact. Queue loading is asynchronous: the player page must not restore the last saved queue while a new one is loading, and exit must invalidate pending loads so a late callback cannot recreate the player. TemporaryPlaylistTest covers these limits.

  • Music status-bar fullscreen and cutouts (2026-10-12): keep the player's edge-to-edge blurred background and per-view insets. Share only the window policy and safe-inset calculation with StatusBarFullscreen; installing its root-padding listener would duplicate the music page's bottom controls/drawer insets. Fullscreen ignores status-bar and cutout padding on all edges, including transient status-bar reveals. Normal mode still reserves visible status-bar space. Reinstall listeners after orientation reinflation and request fresh insets when focus returns.