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.DolbyVisionFallbackExtractorsFactorychecks 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.DolbyVisionFallbackTestincludes 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_UNSPECIFIEDbut only decoder failures had a recovery path; the Play button calledplay()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,PlaybackConnectionTestandPlayerNetworkRetryTestcover 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.
PlaylistMenuLoadingTestblocks 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.
PlaylistImportQueueTestholds 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
MusicEnginebut did not stopMusicService, unlike the music player's exit. Route both throughMusicUi.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
PaneFragmentdelayed 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.HistoryPlayerLoadingTestcovers 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
SwipeGesturebefore deciding to tap.LyricsSwipeTestcovers 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.
MediaProbeshares 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.MediaProbeTestcovers the limits and a generated MKV with corrupt Cues. -
AVI playback + thumbnails (★ traced and settled 2026-07-22,
media31.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", wheremediaparses as an authority, not a path). With no recognizable extension,DefaultExtractorsFactorydegrades tosniff()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 byMp3Extractor(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.synchronizehas a known bug with CBR MP3 (fixed in 1.4.1, upstream commitb09cea9; the root cause isConstantBitrateSeekernot knowing the end of the file and scanning forward forever for a sync word), showing up asParserException: Searched too many bytes. Upgrading to 1.9.0 fixes it (thejellyfin-media3-ffmpeg-decoderversion must be bumped in lockstep, and newer media3 requirescompileSdk≥ 35, which is whycompileSdkwent to 36). - A decoder crashing at runtime ≠ "no decoder": on one test device
c2.android.mp3.decodercrashed 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 inonPlayerError, in two steps: first switch the whole thing toEXTENSION_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 triggeringERROR_CODE_TIMEOUT), drop one more level and justsetTrackTypeDisabled(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
AviExtractordecodes frames fine, soGlFrameGrabberbypasses MMR: start an invisible ExoPlayer decoding into aSurfaceTextureand write a minimal EGL/GL pipeline that draws the external OES texture into an ordinary 2D texture FBO andglReadPixelsit. You cannot just attach anImageReader: 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 officialFrameExtractorbuilds a wholemedia3-effectGL pipeline; not wanting that dependency's size is why this minimal path is hand-written). Do not take the video size fromonVideoSizeChangedto create the Surface — with no output Surface,MediaCodecVideoRenderernever really starts the decode pipeline and that callback never fires, a chicken-and-egg deadlock; read the Format's width/height inonTracksChangedinstead, 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, inonEvents, issue an extraseekToas 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 ownBufferedRandomSourceread-ahead cache; a bareRandomSourceover 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.sampleSizeis 1 anddwLengthcounts bytes, so the muxer cuts the01wbchunks 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 — intest2.avionly 9 of the first 4190 frames start on a chunk boundary. media3'sAviExtractorforwards 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 reportsstate=3 pos=14091— and never advances, so media3 kills playback 10 s later withPlayer 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 onlyvideo/mp4v-esdecoder,c2.android.mpeg4.decoder, advertisesprofile/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 copyremux (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_PREFERsuggests.DefaultRenderersFactorydoes loadExperimentalFfmpegVideoRendererreflectively and logsLoaded FfmpegVideoRenderer, but in the shipped extension itssupportsFormatreturnsFORMAT_UNSUPPORTED_TYPEand itscreateDecoderreturnsnull;libffmpegJNI.soexports 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 wrappingDefaultExtractorsFactoryinAviReframingExtractorsFactory— onlyAviExtractoris 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.
ChunkReaderspreads 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 withsampleSize == 1.
- The symptom is not "no sound": the platform decoder answers
- ★ A false MP3 sync word in leading
0xFFpadding turns into "the whole file plays at 2/3 speed": more than one muxer opens the audio stream with a run of0xFF, 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 withUnexpected audio track timestamp discontinuityabout 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 withffmpeg -map 0:a/-map 0:vand 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, soAviExtractornumbers the chunks as it meets them and labelsP3as 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 bytesfor 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
7fchunks holding no VOP at all ("repeat the last frame"). Fed toc2.android.mpeg4.decoderthey answerfailed 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-widthvop_time_incrementwhose width comes fromvop_time_increment_resolutionin 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.decoderadvertisesprofile/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.
DefaultRenderersFactoryloadsExperimentalFfmpegVideoRendererreflectively and logsLoaded FfmpegVideoRenderer, but in the shipped extension itssupportsFormatreturnsFORMAT_UNSUPPORTED_TYPEandcreateDecoderreturns null, andlibffmpegJNI.soexports 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=3toplayer: endedagainst the file's own duration is a number, and driving playback witham start -a android.intent.action.VIEW -d file:///... -n com.twig.app/.ui.ViewIntentActivitypluslogcatneeds nobody watching the screen. Better still, run the realAviExtractorthrough 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.
- Frame order. MPEG-4 ASP stores frames in coding order and displays them in another:
- Internal media source URIs must carry a real extension: non-local backends used to
get a fake URI with no path (e.g.
-
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
TsExtractorhard-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 useProgressiveMediaSource.Factory's two-argument constructor to nameTsExtractorexplicitly instead of relying onDefaultExtractorsFactory'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:
0x86officially 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.0x83is TrueHD/Atmos, and media3 has no TrueHD/MLPElementaryStreamReaderanywhere inextractor.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 useAc3Readerto 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.0x90is 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 newPgsTsReader: one PES packet holds exactly one complete PGS segment, timestamps come from the PES packet's own PTS (unlike a standalone.supfile 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(withFLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS) throughBluRayTsPayloadReaderFactory; nothing is rewritten wholesale. - Before deciding whether a stream_type is covered by an official descriptor, read
readEsInfoinTsExtractor's source — it only lets a registration_descriptor override the raw PMT stream_type when that type is0x05/0x06(generic placeholders), so concrete values like0x83are unaffected and there is no conflict with that logic.
- TsExtractor's newer API changed: the old
FLAG_EMIT_RAW_SUBTITLE_DATA+SubtitleParser.Factory.UNSUPPORTEDcombination now throwsIllegalStateException: Legacy decoding is disabledin the newerTextRenderer— the whole "raw samples + legacy SubtitleDecoder" path is gone. You must omit that flag and pass a realDefaultSubtitleParserFactory, converting samples toapplication/x-media3-cuesduring 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) andAc3Util.parseAc3SyncframeInfo(one file's AC3 frame header had afrmsizecodoutside 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).SafeTsPayloadReaderadds a defensive try-catch, applied uniformly atBluRayTsPayloadReaderFactory's exit (covering both our own readers and those delegated to the built-in factory): swallow the exception, drop that whole packet, callseek()to reset internal state and carry on. - Never allocate in a hand-written
DataSource'sread()hot path:M2tsStrippingDataSourceallocated aByteArray(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 requestedlengthas 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.
DefaultLoadControlonly needs 2 s of buffer (bufferForPlaybackAfterRebufferMs), so the buffering policy is not to blame. The real cost is in media3's ownTsBinarySearchSeekerfinding 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-levelcodec_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 rawstream_typebyte 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 inmapping.txtby hand (you will hit the illusion of different classes sharing the same obfuscated short name — it looked likeAES256Options/OpenJSSEPlatform, entirely unrelated classes, simply because a real retrace was never run). Use the SDK's"$ANDROID_HOME"/tools/proguard/bin/retrace.shwithapp/build/outputs/mapping/release/mapping.txtto resolve properly at method level.
- Packets are 192 bytes, not 188: BDAV prefixes every TS packet with a 4-byte
timestamp, while media3's
-
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 ownSeriesId. - 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.mkvcontains2160,5,1and265, any of which could be mistaken for an episode number. SoSxxExxreturns immediately on a match and nothing after it is even looked at;E01/EP01comes next; "bare number" comes last and is the strictest (the number must be the last segment of the name, which is whyShow 1080p BluRay.mkvis 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 every01.mp4on 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
surfaceCreatedcallback, 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 backend knows the series structure (
-
The player quits mid-playback and the app then asks for the master password again (★ traced and settled 2026-08-31,
media31.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 threecom.twig.apprecords, alljava.lang.OutOfMemoryError … target footprint 268435456, growth limit 268435456on theExoPlayer:Playbackthread (plus one nativeSIGABRTinMediaCodec_loopwhose abort message wascould not create MediaCodec.BufferInfo object— the same starved heap, just from the JNI side). The unlock prompt is not a bug:Secretskeeps the DEK in a@Volatilefield, so a dead process meanslocked()is true again andSecurityUi.gatedoes exactly what it should. Diagnosing this from the unlock symptom alone leads nowhere; go readlogcat -b crashfirst.- Root cause:
MediaPlayerActivitybuilt itsExoPlayerwith noLoadControl, taking media3's defaults —DEFAULT_VIDEO_BUFFER_SIZEis 125MB andDEFAULT_MUXED_BUFFER_SIZE137MB, against adalvik.vm.heapgrowthlimitof 256MB. AddBufferedRandomSource'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 againstLOCAL_PLAYBACK_SCHEMES=file/content/data/android.resource/rawresource/asset. Network playback goes throughtwig:///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
DefaultLoadControlwithtargetBufferBytes= 48MB and 15s/30s duration bounds. The byte cap is what governs high-bitrate files; the duration bounds are what governs everything else. largeHeapwas deliberately rejected: the device'sdalvik.vm.heapsizeis 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.GlFrameGrabberbuilds its ownExoPlayerand 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.
- Root cause:
-
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.TEMPin 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.TemporaryPlaylistTestcovers 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.