Strawcore — new channel_feed_rss(channel_url) and subscription_feed
(bulk fan-out 50x via tokio buffer_unordered). Fetches the YouTube
Atom RSS at /feeds/videos.xml?channel_id=UCxxx. Each call is
~50-150ms vs ~500ms for the InnerTube channel_info page-scrape.
Deps added to strawcore wrapper Cargo.toml: reqwest (rustls-tls),
quick-xml, futures. reqwest dedupes against strawcore-core's
existing reqwest dep.
App — SubscriptionFeedViewModel.fetchChannelInto swapped to
channel_feed_rss. Parallelism cranked 12 -> 50 since each fetch is
lightweight now. perChannelMax dropped 30 -> 15 (the RSS upstream
cap is 15). RSS doesn't carry duration / viewCount / avatar — those
backfill on tap-through via the existing streamInfo path. Avatar
opportunistic-refresh dropped from this path (lazy-load on
ChannelScreen open is enough).
Hide-shorts content filter — new util/ContentFilter.kt with
looksLikeShort() (URL /shorts/ match OR title contains
'#shorts'/'#short'). Settings toggle defaults off. Filter applies
at row-emit in SubsPane, SearchScreen, ChannelScreen. Paid +
age-restricted stubs in place for vc=57 when strawcore-core gets
the flags.
Expected refresh time on 50 subs: ~30s sequential -> ~1s parallel-50
RSS.
WorkManager periodic worker hits the repo's index-v2.json, parses
the highest versionCode for our package, compares with
BuildConfig.VERSION_CODE. When newer: posts a notification with
ACTION_VIEW on the APK URL — Android's DownloadManager picks it up
and the system installer takes over. No INSTALL_PACKAGES perm
needed.
Settings:
- Check for updates toggle (default on — closing NewPipe's silent
staleness gap is the explicit motivation)
- Interval picker (1h / 6h / 24h, default 6h — WorkManager has a
15-min periodic floor anyway)
- Last-checked timestamp + 'update available' tag when caught-up
state is dirty
- Check now button — runs the same path as the worker so behaviors
stay identical
Cold start fires one check too so users see pending updates without
waiting a full interval.
R8 keep-rule for UpdateCheckWorker added — WorkManager instantiates
workers by name via reflection.
YT/NewPipe-style — when ResumePositionsStore has an entry for a
video, paint a 3dp red bar across the bottom of the thumbnail
showing position/duration. Reads instantly as "you started this."
Consolidated the duplicate thumbnail render logic across 6 row
sites (FeedRow, RecentRow, ResultRow, ChannelVideoRow, RelatedRow,
PlaylistsScreen) into a single feature/player/VideoThumbnail
composable. Includes the existing duration-pill overlay + the new
progress bar. ThumbnailProgressOverlay is a BoxScope extension so
custom thumbnail compositions can drop it in without going through
the full helper.
ResumePositionsStore — SharedPreferences-lite, JSON-blob keyed by
videoId. Caps at 500 entries, prunes oldest on add. Skips trivial
positions (< 5s) and clears near-end (within 5s of duration) so a
finished video doesn't auto-resume to its credits.
PlaybackService — 5s polling job + onIsPlayingChanged(false) +
onDestroy capture write player position via NowPlaying.streamUrl →
videoId. Runs on Main so the player read is thread-safe; store
write is SP.apply() async.
setPlayingFrom — when caller passes startPositionMs=0L AND
Settings.autoResume is on, lookup the saved position and use it.
Surface-handoff path (inline ↔ fullscreen) is untouched —
MediaController already holds its own position across surfaces.
This only fires on fresh opens (cold start, app update, video
re-tap from history).
R8 (minify + resource-shrink) flipped on for BOTH debug AND release
variants — we publish the debug APK to fdroid (per existing
pipeline), and the audit-flagged Log.d strip discipline required R8
to actually run on the variant we ship.
New strawApp/proguard-rules.pro covers:
* UniFFI bindings (uniffi.strawcore.*) — reflective FFI dispatch
from Rust side, must survive minification
* JNA — Library subclasses reflectively loaded by name
* kotlinx-serialization @Serializable — generated $$serializer
companions, kept via both the package-anchored rule and the
annotation-wildcard rule for belt + suspenders
* Media3 session Parcelables (cross-process via Binder)
* Compose runtime + Strawcore exception hierarchy
Surface-handoff polish on inline ↔ fullscreen transitions:
setKeepContentOnPlayerReset(true) on both PlayerViews (inline in
VideoDetail + fullscreen Player). When the detaching view's player
is nulled on dispose, it holds the last rendered frame instead of
flashing black. The receiving view's surface takeover then renders
the next frame without the ~1-frame black gap. Round-4 audit
HIGH-5 was the closest writeup.
Expected APK-size win from R8: ~30-40%. Need real-device
verification post-install — the keep rules are best-effort and a
missing rule manifests as runtime ClassNotFoundException or
silently-broken kotlinx-serialization decoding.
Three landing together.
Bottom-clear sweep:
Channel + Search + Subs feed + History + Playlists + Downloads
all had content rendering under the system nav bar and the
minibar overlay. Added a shared `util/BottomInsets.kt`
`rememberBottomContentPadding()` that combines nav-bar inset +
reactive 72dp minibar reserve (zero when nothing's playing).
Plumbed into every LazyColumn's contentPadding. Same pattern
Settings vc=50 used, lifted into a reusable helper.
DASH/HLS max-resolution cap:
Round-7 audit MED-3 — the user's max-resolution preference only
affected the videoOnly/combined picker. DASH manifests bypassed
the cap because Media3's ABR picked variants freely. New
Player.applyMaxResolutionCap() pushes TrackSelectionParameters
.setMaxVideoSize(MAX, ceiling) into the controller before
prepare(). Auto = MAX_VALUE = unconstrained. Mid-stream setting
changes take effect on next video.
Pause on headphone disconnect:
User-reported bug — wired headphones died, player switched to
phone speaker instead of pausing. ExoPlayer's
setHandleAudioBecomingNoisy honors Android's AUDIO_BECOMING_NOISY
broadcast and pauses on the standard "headphones pulled" event.
Wired into PlaybackService at construction + StrawApp.globalScope
collector so flipping the setting mid-session takes effect on
the already-built ExoPlayer. New Settings → Pause on headphone
disconnect toggle, default on (matches every other Android
media app's UX).
Last rows of Settings were rendering under the 3-button nav and
under the floating minibar. Now:
* Column gets .navigationBarsPadding() so it clears the system
nav bar / gesture bar at the bottom.
* Reactive minibarReserve (72dp when NowPlaying.current != null,
else 0dp) added as a tail Spacer to clear the 64dp BottomCenter
minibar chip. Only consumed when something's actually playing —
no wasted space otherwise.
Settings → Autoplay section now has a third toggle: Auto-start
playback (default on). When on, opening a fresh video starts
playing immediately on the detail page. When off, the page renders
with the thumbnail + Play overlay and you tap to start.
Independent of the end-of-queue autoplay mode and the back-from-
fullscreen behavior (that already auto-resumes because the
controller is mid-stream — preserved).
Implementation: a single OR into the initial inlinePlaying state in
VideoDetailScreen.
Two requested features in one ship.
Autoplay:
* Settings → Autoplay section. Three modes: Off, Same channel,
YouTube related. Default Same channel — per Cobb 2026-05-26,
"plays next account's video".
* Skip-already-watched toggle, default on. Autoplay picks the
first un-watched candidate (filters History.watches by videoId).
* When STATE_ENDED fires and the queue has no next item,
PlaybackService's autoplay handler picks a candidate per mode,
resolves it via strawcore, and enqueues — which auto-starts
because the queue is empty (enqueueLast routes through
setPlayingFrom in that case).
* SameChannel calls strawcore.channelInfo(uploaderUrl).take(1).
Plumbed NowPlayingItem.uploaderUrl + setPlayingFrom/enqueueLast
sig to carry it forward so the autoplay handler has what it
needs without re-resolving.
* YtRelated re-resolves the current streamInfo and picks
info.related[0]. strawcore returns empty for related today, so
YtRelated falls open to no-op until that extractor work lands —
documented in the AutoplayMode enum help text.
SponsorBlock for queued items:
* The vc=47 known-limitation. Now: when onMediaItemTransition
surfaces a queued item with empty SB segments, fire a
background fetch of SB for that video, then NowPlaying.claim()
again with the freshened segments. The skip-loop (reactive on
NowPlaying.current.segments) picks them up.
* Fetch lives in StrawApp.globalScope — outlives the controller
transition + sheet UI.
Refactor:
* StrawMediaController extensions retyped from MediaController →
Player so PlaybackService can call them on the ExoPlayer
directly. MediaController IS a Player so all existing UI call
sites continue to work.
* Shared extractYtVideoId util in feature/detail/StreamResolution.kt.
The duplicate VIDEO_ID_RE in StrawHome.kt will fold into it next
time that file is touched.
Now you can line up videos. Long-press → "Play next" inserts right
after the current playing item; "Add to queue" appends to the back.
Media3 auto-advances through the queue when each item ends.
Implementation:
* feature/player/Queue.kt — process-wide MutableStateFlow<List<
NowPlayingItem>> that mirrors the controller's MediaItem list
1:1 by index. Append, insertAt, setAll mutators match how the
controller's media items get mutated.
* StrawMediaController.enqueueNext / enqueueLast — build the
MediaItem (same shape as setPlayingFrom), insert into Queue at
the corresponding index, then controller.addMediaItem. Empty-
queue fallback: route through setPlayingFrom (starts playback
immediately) so the user doesn't get a silent no-op.
* PlaybackService Player.Listener.onMediaItemTransition — on auto-
advance, look up the new index in Queue, push the matching
NowPlayingItem into NowPlaying via claim(). Minibar + the SB
skip-loop (both reactive to NowPlaying.current) reflect the
new track without any extra wiring.
* feature/detail/StreamResolution.kt — extracted the
resolveStreamPlayback function out of VideoDetailViewModel so
the queue path can call it for each new item.
* VideoActionsSheet wires Play next / Add to queue rows that
launch into StrawApp.globalScope (process-scoped) — sheet
dismisses immediately for snappy UX, but the strawcore network
resolve lives in a scope that outlives the sheet. Toast on
completion.
* StrawApp exposes appScope via a companion `globalScope` for
fire-and-forget work that needs to outlive Composition.
Known limitation:
* SponsorBlock segments are not fetched for queued items — they
play through without skips. The originally-played item (added
via setPlayingFrom) still gets SB. Folding in lazy per-item SB
fetch on transition is a follow-up.
Build offline playlists from any list, not just one-at-a-time from
VideoDetail.
* Long-press any video row → ModalBottomSheet with title/uploader
header + actions: Save to playlist, Share.
* Save reuses the existing playlist dialog (extracted out of
VideoDetailScreen into feature/playlist/VideoActions.kt and
promoted to public).
* Share fires a system ACTION_SEND with the YT URL.
* Wired across all 5 video-row sites: Search results, Subs feed,
History, Channel videos, Related/More-from-channel on
VideoDetail.
Deferred to next ship:
* "Play next" / "Add to queue" — needs Media3 queue substrate +
per-item streamInfo resolution path. Separate ticket; non-blocking
for the offline-playlist build flow Cobb asked for tonight.
Search results:
- Uploader name split onto its own line at bodyMedium (was bodySmall).
- Clickable when uploaderUrl present — taps land on the channel page.
- Tinted primary when clickable, neutral when not.
- Views/duration moved to a separate line so they don't fight the
larger uploader tap target.
VideoDetail:
- New channel row below the title: avatar (40dp circle, clickable),
name (titleSmall semibold, clickable), subscriber count,
Subscribe/Subscribed button on the right.
- Avatar + subscriber count pulled from the same strawcore.channelInfo
call that already runs for moreFromChannel — no extra round-trip.
- Opportunistically pushes a fresh avatar back to SubscriptionsStore
on resolution so the subs feed picks it up too (mirrors the existing
backfill in SubscriptionFeedViewModel.fetchChannelInto).
Reported: press fullscreen → press system back → VideoDetail re-renders
as a "freshly loaded page" — thumbnail with Play button overlay
visible, audio continuing from the persistent MediaController.
Root cause: `var inlinePlaying by remember(streamUrl) { mutableStateOf(false) }`
at VideoDetailScreen.kt:125 keys on streamUrl, so popping back from
Player remounts the composable with a fresh false. The thumbnail
placeholder Box renders instead of InlinePlayer; audio keeps going
on the shared controller because nothing was stopped.
Fix: default inlinePlaying to true when the shared controller is
already playing this exact stream — almost always the back-from-
fullscreen case. Fresh navigation to a video that isn't currently
playing still gets the thumbnail+Play placeholder as before.
Round-5 audit verdict: SHIP. Zero new CRIT/HIGH after the round-7
honesty check confirmed diminishing returns. Only follow-up was a
stale manifest comment pointing at YT_HOSTS (collapsed to util.YtUrl
in vc=42); rewritten to point at util/YtUrl.kt's ALLOWED_YT_HOSTS.
The 5-round audit loop (rounds 4-8 overall, after vc=38's prior
3 rounds): 18 HIGH + 28 MED + handfuls of LOW landed across vc=39
through vc=43. Most material fixes:
* Rust runtime ensure_initialized wired into every extractor
entry, mutex-first then try_lock (no UI freeze on slow init)
* VideoDetail / Channel / Search VM in-flight cancel + fence
pattern; runCatchingCancellable to defeat the runCatching-eats-
cancellation hazard at every coroutine boundary
* Allowlist gate on every extractor entry point (not just
persistence) — util/YtUrl with scheme + trailing-dot defenses
* Bulk-import write-storm collapse on every store + return-real-
added-count so the import summary doesn't lie at saturation
* SponsorBlock skip-loop pause-skip + 50ms-exclusion drop
* Recapcha URL strip-continue-param in Rust before propagation
* SettingsStore truly atomic+idempotent; PlaylistsStore bulk
* Hostile-zip duplicate-entry rejection + playlist LIMITs
Round-7 Opus audits explicitly flagged the audit loop as past
diminishing returns. Landing the few real items + one race-order
fix; leaving the comment-hygiene tail for the next time someone
reads the files.
MED
R7-1 SearchViewModel cache-preview race: vc=41 wrote the cached
preview to UI before cancelling the prior inFlight, so the
prior coroutine (already past its ensureActive() gate)
could reach its terminal _ui.update and clobber the cache
preview. Moved inFlight?.cancel() above the preview write.
R7-2 StrawActivity.YT_HOSTS duplicated util.YtUrl.ALLOWED_YT_HOSTS
— drift risk where one gets a new host and the other
doesn't. Collapsed StrawActivity.looksLikeYouTube to call
util.isAllowedYtUrl, picking up the scheme + trailing-dot
defenses for free.
R7-3 Dropped dead once_cell dep from rust/strawcore/Cargo.toml.
Round-4's runtime rewrite uses AtomicBool + Mutex; nothing
consumes once_cell anymore.
Audit explicitly called out (and we agree) these as past the value
threshold for further rounds:
- Verified-clean: try_lock no deadlock, ensureActive fence, bad-URL
early-return cancel, duplicate-zip-entry rejection, LIMIT clauses,
SponsorBlock 50ms exclusion drop, applied++ count.
- False positive: H1 "Related videos always empty" — the section
already gates on `d.related.isNotEmpty()`; UI doesn't paint when
extractor stub returns empty.
- Deferred: R8 enable, Nav rememberSaveable, LazyColumn keys,
collectAsStateWithLifecycle, DASH/HLS max-resolution cap,
Gradle cargo-build input/output declarations (CI cost, not
runtime), legacy :app module trim, stale comment cleanup.
Three Opus round-6 audits found important regressions on the vc=39/40
fixes plus a fresh hostile-zip attack surface.
HIGH
R6-1 SearchViewModel.submit fence-by-query swallowed valid
results. `onQueryChange` mutates _ui.value.query without
cancelling inFlight (for reactive cache filtering as the
user types). The vc=40 string-equality fence treated a
still-valid result as stale just because the user kept
typing after submitting. Re-fenced by Job identity via
ensureActive() — only a fresh submit (which calls
inFlight?.cancel()) invalidates us, mere typing doesn't.
R6-2 SettingsImport.run wrapped `runInner` (suspend) in plain
runCatching, swallowing CancellationException and
surfacing it as a Result.failure that produced a misleading
"import failed" banner on a user-back abort. Migrated to
runCatchingCancellable — exactly what round 5 added the
util for; this call site was missed.
R6-3 VideoDetail/ChannelViewModel.load early-return on bad URL
didn't cancel inFlight. An in-flight prior load could
resolve past the suspension point, see its fence pass
(loadedUrl unchanged because we didn't update it on the
rejected call), and clobber the "Unsupported URL" error
banner the user is looking at. Now: inFlight?.cancel() +
inFlight = null before the early return.
R6-4 Rust ensure_initialized mutex contention pathology. The
vc=40 mutex-first ordering correctly serialized init but
blocked N concurrent tokio workers for the full duration
of a slow ReqwestDownloader::new() (e.g. ~7s on a 6s DNS
timeout). On a 4-core phone that froze the app for 7s.
New: backoff check FIRST (lock-free), then try_lock — if
someone else is initializing, return immediately. Caller
gets one DownloaderMissing they can retry past, instead
of multi-second UI lockup.
MED
R6-5 Hostile zip with duplicate `newpipe.db` entries could
masquerade a benign first DB past any pre-validation and
ship the second malicious one (ZipInputStream walks in
order; second write overwrites). Now: reject the archive
when either `newpipe.db` or `preferences.json` appears
twice.
R6-6 importPlaylists outer + inner queries had no LIMIT — a
crafted DB with 10M playlist rows or 10M items could walk
unbounded cursors into memory even under the 256 MB DB
size cap. LIMIT 256 / LIMIT 5000 now match the discipline
on the other import paths.
R6-7 SponsorBlock pickActiveSegment had a `posSec < endSec -
0.05` exclusion that combined with the 150ms polling
cadence missed short (<200ms) filler/reminder segments
entirely. Dropped — the rate-limit work made it
unnecessary.
R6-8 SettingsImport.importSettings `applied++` was outside the
`want != have` branch — inflated the import-summary count
to "12 settings applied" when only 2 changed.
Deferred:
- PlaylistsStore URL canonicalization (still deferred — needs
shared YT-id-extract util, not blocking)
- SQLite header magic validation (low-value defense-in-depth)
- SP write reorder serialization (bigger refactor)
- updateAvatar coalesced persists (12 parallel = wasted CPU
but not visible)
- Proguard rules staging (paired with R8 enable, both deferred)
- DASH/HLS maxResolution cap via TrackSelectionParameters
(real bug but needs careful Media3 wiring)
Three parallel Opus round-5 audits ran on vc=39. The big finds were
regressions on vc=39's own fixes — the retry-init wiring was incomplete,
the URL-fence pattern missed CancellationException swallowing, and the
SettingsStore idempotency branch was dead code. Real round-5 work.
HIGH
R5-1 Rust `ensure_initialized` was only called from `init_logging`
— extractor entry points never invoked it, so the 5s-backoff
retry from vc=39 was unreachable. A cold-boot init failure
still bricked extraction for the whole process. Now every
`search()` / `stream_info()` / `channel_info()` calls
ensure_initialized at entry; cheap when INITIALIZED is true.
R5-2 runtime.rs in-progress race: prior shape stamped
LAST_ATTEMPT_MS at the *start* of an attempt, then let
concurrent callers short-circuit via the backoff check
and proceed to call into the extractor before init
completed. New: lock FIRST (mutex IS the in-progress
queue), re-check INITIALIZED under lock, only THEN check
backoff (and only stamp it on failure).
R5-3 `runCatching` in VideoDetailViewModel + SubscriptionFeed
swallows CancellationException — when a job was cancelled
mid-suspend inside channelInfo/RYD/SB, the inner cancellation
was eaten, the lambda returned its default, the job carried
past the runCatching to its terminal write, and the URL fence
let it through because same-URL races can't be distinguished
by string equality. New util.runCatchingCancellable
re-throws CancellationException; all coroutine-body
runCatching sites in the affected VMs migrated.
R5-4 SearchViewModel.submit post-network fence only guarded
`_ui.update`. SearchCache.record + pool rebuild proceeded
for a cancelled query → ghost cache entries for queries
the user abandoned mid-stroke. Now: re-check the query
AFTER the IO suspend and before the cache write.
R5-5 ChannelViewModel.load / VideoDetailViewModel.load now gate
the extractor entry on isAllowedYtUrl(channelUrl/streamUrl).
Prior shape only gated recordWatch persistence — extractor
invocation for poisoned uploaderUrl still happened.
MED
R5-6 SettingsStore.set{MaxResolution,ThemeMode}: vc=39 used
`updateAndGet { r } == r` which is unconditionally true
(lambda ignores prior) — the in-memory equality check was
dead code. SP-side check still gated the disk write so the
feature worked, but the dead branch was a comment-vs-code
liar. Rewrote with explicit before-capture + equality
gate.
R5-7 SponsorBlockSkipLoop polled `controller.currentPosition`
every 150ms even when paused — paused-overnight playback
ate ~24k binder calls/hour. Now: when `!isPlaying`, sleep
1s and continue.
R5-8 StrawApp.appScope had no CoroutineExceptionHandler — an
uncaught throwable in a top-level launch could crash on
cold start even with SupervisorJob (top-level failure
still propagates to default handler). Added handler that
logs via strawLogW.
R5-9 YtUrl.isAllowedYtUrl now requires http/https scheme
(schemeless `//host/...` URLs no longer pass) and strips
a single trailing dot from host (RFC FQDN form). Defense
in depth.
LOW
R5-10 NowPlaying.set() removed — non-CAS setter footgun
alongside the race-free claim()/CAS path. No external
callers (grep clean). Doc-comment updated.
Deferred (later round / different scope):
- PlaylistsStore URL canonicalization (round-5 MED-1 — needs a
shared YT-id-extract util; not blocking).
- Release R8 + Nav rememberSaveable (still deferred).
- LazyColumn keys + collectAsStateWithLifecycle (cosmetic).
- SponsorBlockSkipLoop currentMediaItem binding (round-5
deferred).
Three parallel Opus max-effort audits ran on vc=38. No new CRITs (the
LogDump + VM-error-scrub chain held), but real new HIGHs across VMs
that weren't touched in rounds 1-3 + the Rust runtime's brittle
one-shot init.
HIGH
R4-1 Rust runtime::ensure_initialized was one-shot via Once.
First-call failure (cold-boot in airplane mode, transient
DNS/SELinux denial on first TLS init) consumed the Once slot
and bricked the extractor for the rest of the process —
every subsequent search/streamInfo/channelInfo returned
DownloaderMissing forever. Replaced with AtomicBool + 5s
backoff retry; success closes the door, failure retries on
the next call.
R4-2 VideoDetailViewModel.load tracked no inFlight Job.
Activity-scoped VM is reused; tap video A → quickly tap a
related-video B → both loads race, slower-finisher wins.
A's resolved payload (different itags, different SB
segments, wrong title chip) could render on the B detail
page; recordWatch logged B while the player streamed A.
Now: inFlight?.cancel() at top, fenced terminal writes with
loadedUrl-stable guard. Same shape applied to
ChannelViewModel (had no in-flight tracking at all).
R4-3 `_ui.value = _ui.value.copy(...)` lost-write patterns
survived round-3's pass in SearchViewModel + VideoDetail +
Channel. Migrated all to `_ui.update {}` — same atomicity
regression class round 3 was supposed to close. Submit/load
terminal writes also now fence against late-arrivals.
R4-4 HistoryStore.recordAllWatches reported `size_after -
size_before` to SettingsImport — at a saturated store the
post-state size equals the pre-state size even when 20
fresh imports landed and 20 older entries got truncated.
User saw "0 watch history imported" when 30 actually
landed. Now: recordAllWatches/recordAllSearches return an
AtomicInteger-counted actual-fresh-added count from inside
the CAS lambda; SettingsImport plumbs through to the report.
R4-5 SubscriptionFeedViewModel.refresh() filtered to stale-only
— user-initiated tap of Refresh was a silent no-op when
every channel had been refreshed in the last 28min.
Split: refresh() forces fan-out across every sub;
refreshIfStale() keeps the TTL filter. Both share
refreshInternal(force: Bool).
R4-6 SettingsImport.importPlaylists called create() + addItem()
in a loop — both write SP, and addItem walks every playlist
linearly per insert. A NewPipe export with 100 playlists ×
100 items = ~10k SP commits + O(N²) work. New
PlaylistsStore.importPlaylist mints a single Playlist with
pre-attached items, one CAS, one SP write per playlist.
R4-7 VideoDetailViewModel auto-called channelInfo(uploaderUrl)
on every load — no allowlist gate. An extractor-emitted
non-YT uploaderUrl (poisoned related/moreFromChannel)
would have triggered an arbitrary-host network call.
R4-8 Similar shape: VideoDetailViewModel.recordWatch persisted
whatever URL was passed to load() — extractor-emitted non-YT
URLs would have survived in Recent Watches past process
death. Same import-time URL allowlist now gates both.
CVE-1 The reCAPTCHA error path embedded the full google.com/sorry/
URL into the user-visible banner. That URL carries
`continue=<full-signed-googlevideo-url>` — and LogDump's
scrub only matches googlevideo.com hosts. Now: strip the
`continue=` param in Rust before propagating; UI shows a
tappable challenge URL that still solves the rate-limit
when the user opens it.
MED
R4-9 SettingsStore.setMaxResolution/setThemeMode/setCacheEnabled
were not atomic vs toggle()'s updateAndGet pattern. Now
CAS-safe + idempotent (no SP write when the value is
already what's stored).
R4-10 SponsorBlockClient.fetch built the URL via string concat
with un-percent-encoded JSON-shaped categories list.
Switched to HttpUrl.Builder().addQueryParameter() — okhttp
does the right escaping. SB happens to accept the raw form
today; this guards future user-typed categories.
R4-11 strawHttpClient() synchronized on the interned
STRAW_USER_AGENT string literal — any unrelated code that
happened to lock the same literal could contend. Replaced
with lazy(SYNCHRONIZED) — same one-shot init, no shared
global lock.
R4-12 DownloadsScreen.queryDownloads ran on the main coroutine
every 1-5s. DownloadManager.query is a ContentResolver IPC
+ SQLite cursor walk; on devices with hundreds of historical
downloads it stuttered. withContext(Dispatchers.IO).
R4-13 Co-located the YT host allowlist (was inline in
SettingsImport) into util/YtUrl.kt — VideoDetailViewModel
now imports the same function. Future host changes are
one edit.
Deferred to round 2-5:
R4-MED — Nav.kt has no rememberSaveable / Parcelize on Screen
sealed types. Process-death loses entire back stack.
Needs Parcelize plugin add + listSaver — bigger refactor.
R4-HIGH — Release isMinifyEnabled = false / no R8. Needs
comprehensive keep-rules for UniFFI + kotlinx-serialization
before flipping safely. Holding for a dedicated round.
R4-MED — LazyColumn key= missing in 5 list sites; quick win
but cosmetic, won't slip into post-round-5 ship.
R4-MED — collectAsStateWithLifecycle bulk-replace.
R4-MED — SponsorBlock skip-loop should bind segments to
controller.currentMediaItem to avoid one-tick misapply on
track changes.
Three round-3 Opus audits ran on vc=37. NO new CRITs (round-2 work
held) but real new HIGHs — several were vc=37 own-goals.
HIGH
R3-1 recordAllWatches dropped import on capacity=0. Old: when
watches store hit MAX_WATCHES (50), capacity=0, the whole
import was discarded silently. New: build fresh import list
capped at MAX_WATCHES, then combine + take(MAX_WATCHES) so
imports always land (truncating oldest current entries).
Also: skip SP write when next === before (no-op import on
already-saturated store no longer thrashes disk).
New recordAllSearches with same shape — round-3 CVE MED-6:
importHistory was per-row recordSearch.
R3-2 / CVE-2 SubscriptionsStore.addAll counter race. The vc=36
size-delta fix snapshot `cur = _subs.value` BEFORE
updateAndGet, so a concurrent toggle inflated `added`. New:
AtomicInteger reset at the start of each lambda re-run,
counted by checking each ref against the pre-image inside
the CAS. Exactly the additions THIS call made.
R3-3 refresh() empty-channels didn't cancel inFlight. Cancel
moved to the top of refresh() unconditionally so a refresh
on the prior sub set is killed before the empty branch
clears + wipes disk.
clearInMemoryCache also cancels inFlight — without it, a
cache-disable flip during a refresh could see fetchChannelInto
re-populate the just-cleared map.
R3-4 Non-atomic `_ui.value = it.copy(...)` at init hydrate path
and clearInMemoryCache. Replaced with `_ui.update {}` for
atomicity vs concurrent refresh writes. init's
lastFetchedAt write now uses maxOf so it never regresses
past a fresh refresh value.
CVE-1 state.error rendered raw UniFFI/Rust error strings to UI
— NetworkError::Recaptcha { url } embeds full signed
googlevideo URL. User screenshots a "reCAPTCHA at <URL>"
banner → leak. All four VMs (Channel/Detail/Feed/Search)
now scrub via LogDump.scrubLine before storing.
CVE-3 pruneCacheToSubs in init can clobber concurrent
fetchChannelInto writes. init's putAll → putIfAbsent so
a fresh entry from a parallel refresh isn't overwritten
with disk-stale data.
CVE-4 SIGNED_PARAM_RE over-redacted short tokens (`\bn=`
matched `n=42` counters from any wrapped lib). Split into
SIGNED_PARAM_LONG_RE (signature/sparams/lsig/cpn/expire/
pot/sig/key — match anywhere) and SIGNED_PARAM_SHORT_RE
(n/mn/ms/mo/pl/ip/ei — require `[?&]` immediately before).
Func-HIGH-1 refresh() swallowed CancellationException as a
user-visible error. Spam-tapping Refresh produced a
"refresh failed: StandaloneCoroutineCancelled" banner.
Re-throw CancellationException; catch only real errors.
MED
R3-5 reactiveFilter did N `.lowercase()` allocations per
keystroke. Switched to contains(ignoreCase = true) — zero
allocations.
CVE-MED-5 FileProvider cache-path was "." (whole cacheDir,
including SettingsImport workdirs). Narrowed to "logs/";
LogDump.capture now writes to cacheDir/logs/ to match.
CVE-MED-7 Downloader.Request.setTitle was the raw title
(bidi-override / control chars possible). Switched to
safeTitle.
CVE-MED-8 Rust hello_from_rust value-log scrubbed to name_len.
Func-LOW-4 recordAllWatches skip-write-on-no-change (`next !==
before`).
Deferred to a follow-up (not user-facing this ship):
R3-MED-6 — Settings setMaxResolution/setThemeMode/setCacheEnabled
not atomic via updateAndGet. Inconsistent with toggle()
but the Switch UI throttles enough that no real race.
R3-MED-8 — Minibar play-button reads live controller.isPlaying
instead of listener-tracked. One-frame oscillation on
super-fast double-tap.
R3-LOW — collectAsState vs collectAsStateWithLifecycle drift.
Func-LOW-6 — refreshIfStale isActive check is TOCTOU on a
non-existent multi-threaded call surface (LaunchedEffect
+ button are both Main).
Three round-2 Opus audits ran on the vc=35+vc=36 surface. CVE
returned no new CRITs (round-1 fixes hold) but found 5 new HIGH.
Code-health found 2 CRIT — both my own vc=35 regressions. Function-
correctness found 5 BROKEN that the round-1 sweep missed.
CRIT (from code-health round 2)
R1 Subs feed avatar-backfill self-cancel loop.
Subscriptions.updateAvatar emits a new _subs reference;
SubsPane's LaunchedEffect(subs) reacts → refreshIfStale →
refresh() → inFlight.cancel(). With N channels needing
backfill the parallel-12 batch degenerated into N sequential
single-channel fetches that kept aborting each other. Gated
refreshIfStale on inFlight.isActive != true.
R2 HistoryStore.recordAllWatches O(N²) input.
The vc=35 bulk-import path collapsed N SP writes into 1
(good) but used ArrayList.add(0, item) inside a loop walking
up to 50k input rows before take(50). ~1.25B shifts worst
case. Rewritten: walk newest-first, filter blanks + seen
IDs, stop at MAX_WATCHES. O(N) bounded by output cap.
HIGH (from CVE round 2)
CVE-1 PlayerScreen + VideoDetailScreen rendered raw
error.message into the UI — Media3 HttpDataSource
exceptions include the full request URI with sig=/pot=.
User screenshots a playback error to a chat → full
session credentials in the picture. Both surfaces now
scrub via LogDump.scrubLine before rendering.
CVE-2 SubscriptionsStore.addAll counter race —
updateAndGet's lambda re-runs on CAS retry; var-outside-
lambda increment double-counted. Now derives `added`
from next.size - cur.size delta.
CVE-3 sweepStale ran deleteRecursively() on cacheDir (up to
~256MB) on the main thread inside Application.onCreate.
Moved to appScope.launch(Dispatchers.IO).
CVE-MED-2 Expanded LogDump.SIGNED_PARAM_RE alternation to
include n / lsig / ei / key / sparams.
CVE-MED-3 PlayerScreen + VideoDetailScreen error handlers now
also NowPlaying.clear() so the minibar doesn't keep
claiming a dead session is loaded.
CVE-MED-4 SettingsImport validates imported subscription /
playlist / history URLs against IMPORT_ALLOWED_HOSTS
at import time. Hostile NewPipe export can no
longer smuggle attacker-controlled URLs.
HIGH (from code-health round 2)
R3 Store constructors hit SP + JSON-decode on main thread at
Application.onCreate. Small stores (Settings, History,
Subscriptions, Playlists) stay eager — sub-millisecond
cost. Heavy stores (FeedCache ~225 KB, SearchCache ~150
KB) now lazy-init: their `init()` just stashes
applicationContext; the actual Store + disk decode is
built on first `get()`, which happens from VM IO-dispatched
coroutines.
R4 SearchViewModel.pool race with init coroutine. Switched
pool to a plain @Volatile var (no observers anyway — LOW-
R14) and exposed rebuildPool() so the cache-toggle handler
and a future explicit hook can refresh it.
R5 SubsPane first-paint empty flash. Seeded
SubscriptionFeedUiState(loading = true) in the VM's
initial state — the init coroutine always runs.
R6 Dropped dead uploaderAvatar field on StreamItem. Written
three places, read zero. Saved bytes in every cache entry.
R7 Split mergeFromCache into pruneCacheToSubs + mergeFromCache
(no side effect in the reader). Callers do prune then
merge.
R8 Settings cache-disable wipe now runs on Dispatchers.IO
(3 SP-edit calls were on the UI thread).
HIGH (from function-correctness round 2)
B1 refresh() empty-channels also wipes disk cache (was
in-memory only — disk orphans accumulated).
B2 Settings cache OFF→ON now triggers feedVm.refresh() +
searchVm.rebuildPool() so the user doesn't have to
navigate away and back to repopulate.
B3 SearchViewModel.submit() cache lookup was still doing
SearchCache.get().load() on main (CRIT-C1 was only
partial). Now uses entries.value (StateFlow snapshot).
B5 SearchCacheStore.record now atomic via MutableStateFlow
+ updateAndGet (was load()→write() with no atomicity, so
concurrent records lost entries).
Q9 History.recordWatch wrapped in withContext(Dispatchers.IO).
Q11 Minibar onPlayerError also stops the controller + clears
media items (was leaking dead controller state).
MED
R10 Added comments at the 4 pre-flight NowPlaying checks
noting they're optimizations, claim() is the safety guard.
Prevents a future refactor recreating the round-1 race
after deleting "the guard."
R11 Minibar Toast continues but now layered with the
controller.stop() + clearMediaItems().
CVE-MED-1 NowPlaying.claim updates metadata fields when the
same URL is re-claimed (was returning false unconditionally,
pinning truncated search titles over fresh-from-detail titles).
Q3 onQueryChange clears state.error so a failed-submit's
banner doesn't haunt the next reactive preview.
Deferred to vc=38 (intentional cost/benefit):
CVE HIGH-2 (Rust strawcore::search query= info-log) — needs
a separate strawcore-core edit + rebuild. Logged as a
follow-up.
CVE HIGH-3 (DownloadManager setVisibleInDownloadsUi
deprecated on API 29+) — only the direct-streaming
download replacement is a full fix; that's a multi-day
refactor.
Q5/Q8 (SettingsImport hostile zip silent abort UX) —
cosmetic dialog-title fix.
Q12 (loadedUrl assignment ordering) — pre-existing,
deferred again.
The deferred items from the vc=35 audit-fix sprint. Smaller surface,
real impact:
HIGH-C6 — atomic setPlayingFrom claim
StrawMediaController.setPlayingFrom previously did
if (NowPlaying.current.value?.streamUrl == streamUrl) return
setMediaItem(...); prepare(); play()
NowPlaying.set(...)
When the inline player and fullscreen Player effects fired in the
same composition pass (an inline → fullscreen transition), both
checks could see the stale NowPlaying value, both passed the
guard, both ran setMediaItem + prepare + play. Result: an audible
"did the video just restart?" stutter that was hard to reproduce.
New: NowPlaying.claim(item) uses MutableStateFlow.compareAndSet
in a CAS loop. Returns true ONLY for the caller that won the
race; losing caller bails before touching the controller. The
guard is now actually atomic, not a check-then-set.
MED-Q11 — minibar surfaces playback errors
Background button takes the user to Home with audio continuing in
the foreground service. If that audio then fails (transient network
drop on the resolved URL), neither the inline-player error listener
nor PlayerScreen's exist anymore — only the minibar is observing.
Added onPlayerError to MinibarOverlay's listener: Toast the
errorCodeName + clear NowPlaying so the minibar hides itself
rather than claiming a dead session is loaded.
MED-Q15 — pre-compute recencyScore once
mergeFromCache's compareByDescending invoked recencyScore() twice
per pair (compareBy semantics), so ~1800 regex matches on a 900-
item merge. Pair the score with the item once, sort the pair, take
the items back. N matches.
MED-C13 — Settings cache-wipe also clears in-memory VM
SubscriptionFeedViewModel.clearInMemoryCache() exposed; Settings's
Switch.onCheckedChange(false) now calls it alongside the disk
wipe. Without this the feed kept rendering its in-memory mirror
until process death.
MED-C5 — drop StrawHome.formatDurationShort
Near-duplicate of util.formatDuration. Used util's version + the
existing `if (durationSeconds > 0)` guard at the call site already
produces identical output (util returns "" on sec <= 0).
MED-C19 — drop unused Surface import in StrawHome.
NowPlaying gained one public method (claim). Everything else is
internal-only churn.
Three Opus max-effort audits (CVE/security, code-health, function-
correctness) on the vc=34 surface returned a consolidated punch list
of 5 CRIT + 14 HIGH + ~10 MED. This commit lands all CRIT + HIGH +
the cheap MEDs in one cohesive pass.
CRIT — privacy + main-thread blocks
S1 IosSafeHttpDataSource was logging full pre-signed googlevideo
URLs (signature/sig/pot/expire/cpn) via raw android.util.Log.i
(no DEBUG gate). Then LogDump.capture would scrape its own PID's
logcat and ship it via the share sheet — a "report a bug to
Telegram" silently exfiltrated session credentials. Fixed by
switching all log calls to strawLogD/strawLogW (gated on
BuildConfig.DEBUG), dropping the full-URL log entirely, and
adding a regex scrub pass in LogDump for googlevideo URLs +
signed-param keys before the file hits disk.
S2 Downloader.enqueue handed signed googlevideo URLs to the system
DownloadManager, where they leak into DM's SQLite, logcat, the
system notification, and apps holding ACCESS_DOWNLOAD_MANAGER.
Set VISIBILITY_HIDDEN + setVisibleInDownloadsUi(false) so the
URL never surfaces in any external surface. Added the
DOWNLOAD_WITHOUT_NOTIFICATION permission DM requires.
S3 SettingsImport extracted the user's full newpipe.db (every sub,
watch, search) into cacheDir, deleted on finally. A force-kill
mid-import left the DB on disk indefinitely. Wrapped cleanup in
withContext(NonCancellable), switched workDir to createTempFile
(unguessable name), and added StrawApp.onCreate sweep of stale
newpipe-import-* dirs on every cold start.
C1 SearchViewModel.reactiveFilter ran a fresh SharedPreferences.
getString + Json.decodeFromString on FeedCache (~225 KB) AND
SearchCache (~150 KB) on EVERY keystroke. Hoisted into a
MutableStateFlow<List<StreamItem>> pool, built once on
Dispatchers.IO at VM init and refreshed after each successful
submit. Reactive filter now walks an in-memory list.
C2 SubscriptionFeedViewModel.init did the same FeedCache.load()
synchronously on the main thread at construction (first compose
pass blocked on the JSON decode). Moved into
viewModelScope.launch + withContext(Dispatchers.IO).
HIGH — function correctness + defense in depth
B1+B2 SearchScreen when-branch order: loading + error short-
circuited before the results branch, hiding the cached
preview the VM explicitly kept visible on cache-hit + on
network failure. Refactored to render the cached list under
a thin progress bar / error banner instead.
B3 Downloads "tap completed row" silently failed since minSdk 24
(FileUriExposedException on the file:// URI). Route through
FileProvider with new file_paths.xml entries for
Movies/audio + Movies/video.
B6 Manifest VIEW intent-filter was missing music.youtube.com and
youtube-nocookie.com hosts even though YT_HOSTS allowed them
— added both.
B7 SponsorBlockSkipLoop fired one Toast per skip with no rate
limit; sponsor-dense videos painted 20+ Toasts over 40s after
the seeks completed. 3s rate limit per cur.streamUrl.
Q8 resolvePlayback.pickVideo fallback used maxByOrNull when the
comment said "lowest available" — a 480p-capped user on a
1080p-only upload got 1080p (their data cap blown). Switched
to minByOrNull { height } when nothing fits the cap.
S1 SettingsImport extractZip had no size or entry-count caps —
zip-bomb could fill cacheDir. Added MAX_DB_BYTES (256 MB),
MAX_PREFS_BYTES (1 MB), MAX_ZIP_ENTRIES (64). copyBounded /
readBoundedBytes helpers replace the unbounded copyTo /
readBytes.
S2 Manifest: android:allowBackup=false +
android:dataExtractionRules=@xml/data_extraction_rules.xml +
android:fullBackupContent=false. Excludes root/file/database/
sharedpref/external from both cloud-backup and device-transfer
so the user's full search + watch history doesn't ride to
Google Drive.
S3 Dropped isLenient = true from every Json {} instance (7 sites
across data/ and net/). Lenient parser was buying nothing on
data we wrote ourselves and was a hardening gap on the third-
party SponsorBlock + RYD endpoints (community-run; malformed
payload could feed bad timestamps into the skip loop).
S4 SubscriptionsStore.addAll + HistoryStore.recordAllWatches bulk
methods, used by SettingsImport. Per-row toggle was O(N²) +
N SP writes; bulk path is O(N) + 1 write.
C3 SubsPane infinite-scroll LaunchedEffect keyed on
(displayed.size, hasMore) — both mutated BY the effect. The
collector cancelled itself mid-stream and dropped emissions,
producing "scroll to bottom, nothing more loads". Re-keyed on
listState + filteredCount; the collect lambda reads state
through the snapshotFlow producer to avoid stale captures.
C7 liveDrag + playbackSpeed: mutableFloatStateOf instead of
mutableStateOf — no Float boxing on the 100Hz drag callback.
C8 LogDump.capture is now suspend on Dispatchers.IO. The Settings
click handler launches into scope; button shows "Exporting…"
while in flight.
MED — cheap wins picked up in passing
Q9 reactiveFilter clears the cached preview when current query no
longer has any matches (was leaving stale results visible).
Q10 Hide-watched filter excludes blank video IDs from watchedIds
— a blank in the set used to match every malformed-URL feed
item and silently hide them.
Q13 PiP button in VideoDetail bails with a Toast on null
controller OR null resolved playback (was falling through to
enterPictureInPictureMode with no stream).
C17 SpeedPickerDialog row used fillMaxSize inside an AlertDialog;
only the first row got non-zero height. Fixed to fillMaxWidth.
Deferred to vc=36 follow-up (touch surface area we don't want to
churn in the same ship):
- C6 atomic setPlayingFrom guard in StrawMediaController
- S3 (full) — direct-streaming download replacing DownloadManager
- MED-C16 LazyColumn refactor in VideoDetailScreen
- MED-Q12 loadedUrl assignment ordering hardening
Five things land together — same surface, all the data/settings flow:
SettingsStore additions
themeMode: System / Light / Dark (default System)
cacheEnabled: Bool (default true). Single toggle gates both the
subs feed cache (FeedCacheStore) and the new search cache.
Theme override
StrawActivity reads Settings.themeMode and bypasses
isSystemInDarkTheme when Light or Dark is chosen. Changes apply
immediately via StateFlow recomposition — no restart needed.
SearchCacheStore (new)
SharedPreferences JSON of the last 30 searches with 20 items each
(~150 KB cap). Serialized via @Serializable StreamItem (already
serializable since vc=33).
Reactive search
SearchViewModel.onQueryChange now scans the merged corpus (saved
searches + subs feed cache, ~1500 records max) as the user types
past 2 chars. Matches on title or uploader (case-insensitive),
dedup by URL, cap 60. UI shows a "Cached results · …" hint when
the visible list is from cache so users know it's not the network
result yet.
submit() now paints any matching cached query immediately, then
kicks the network. Network results overwrite cache on success and
the cached preview survives network failures so offline users
still see something.
Cache opt-out
Settings switch "Enable local cache" wipes both stores when
flipped off. FeedCacheVM + SearchVM both short-circuit their
read/write paths when the flag is false.
Log dump
util/LogDump captures this PID's logcat (-d -v threadtime --pid=)
to cacheDir, returns a chooser Intent via FileProvider. New
Settings → "Export logs…" button. Toast surfaces the failure
reason if the dump command itself fails (sandbox-restricted
devices etc.).
FileProvider declared in manifest with cache-path "logs"; XML at
res/xml/file_paths.xml. Authority = ${applicationId}.fileprovider.
Channel-avatar issue and the sluggish-load issue both addressed.
strawcore-core (Sulkta-Coop/strawcore @ 7c71511) — separate repo,
already committed + pushed:
Channel parser was only pulling avatar from the legacy
c4TabbedHeaderRenderer branch. The newer pageHeaderRenderer
(most channels with a 2024+ refreshed header — including WTYP)
was returning empty avatars. Added a deep-nested ViewModel walk
for pageHeaderRenderer, plus a metadata.channelMetadataRenderer
.avatar.thumbnails[] backfill. WTYP and other re-headered channels
should now show their icon.
Persistent feed cache (data/FeedCacheStore.kt):
New SharedPreferences-backed JSON store. ~225 KB at the upper
bound (30 subs * 30 items * ~250 bytes), well within SP's comfort
zone. Survives process death.
SubscriptionFeedViewModel:
Hydrates from FeedCacheStore on init. The Subs tab now paints
cached items in one frame on cold start, then refreshes stale
channels in the background.
Persists the cache after each successful refresh via Dispatchers.IO.
Per-channel TTL bumped 10min → 30min (disk cache amortizes the
cost; stale-from-disk + background-refresh feels like instant).
Per-channel timeout 15s → 10s (slow channel rides cached value
instead of stalling the batch).
Parallelism 8 → 12 (less network-bound now that UI doesn't wait
for first byte).
StreamItem now @Serializable so the cache can encode it.
FeedCache.init wired into StrawApp.onCreate alongside the other
SharedPreferences-backed stores.
Subscription feed is now actually a feed instead of a teaser.
Rust (strawcore wrapper)
Added upload_date_relative and uploader_avatar to SearchItem so
Kotlin can see both. strawcore-core already extracts upload_date
relative ("2 days ago") on every StreamInfoItem and uploader_avatars
on most — we were just throwing them away in from_core. Fixed.
StreamItem
uploadDateRelative + uploaderAvatar fields added. Every construction
site (search/channel/detail/feed) plumbs them through.
SubscriptionFeedViewModel
Per-channel cap 5 → 30. With 30 subs that's up to 900 items in
memory; ConcurrentHashMap entries are small enough.
Sort by parsed relative recency (RECENCY_RE on the "N <unit> ago"
string, signed seconds-ago, tied items break by viewCount).
Opportunistic avatar backfill: every successful channelInfo fetch
updates the stored ChannelRef.avatar via Subscriptions.updateAvatar
when strawcore returns a non-null avatar — fixes the "I just subbed
to a channel and the chip has no icon" case where the channel header
parser missed the avatar at subscribe time but the feed-fetch
layout returns one.
SubsPane (StrawHome)
Hide-watched FilterChip (session-sticky). Cross-references
History.watches by 11-char YT video ID; filters out anything you've
already watched. "All caught up — nothing unwatched" empty state.
Infinite scroll: PAGE_SIZE = 20. derivedStateOf-gated snapshotFlow
watches the LazyListState's lastVisibleItem index; when within 5
items of the bottom, bumps visibleCount by 20. "Loading more..."
spinner at the bottom while there's more to show.
Visible-count resets to PAGE_SIZE when the underlying list shrinks
(refresh dropped items, filter just engaged).
FeedRow now shows: uploader · views · "3 days ago".
SubChip
Lettered fallback when ch.avatar is null. PrimaryContainer-tinted
circle with the first letter — no more broken-image placeholder
while the feed-fetch backfills the real avatar.
SubscriptionsStore
updateAvatar(url, avatar) for the backfill path. Atomic via
updateAndGet, persists to SharedPreferences.
Rewrote the drag-to-dismiss state machine. Old version launched a
coroutine per pointer event to call Animatable.snapTo (which is
suspend) — multiple launches racing per frame caused the stutter.
Two-state pattern now:
liveDrag (mutableFloatStateOf) — updated synchronously inside
rememberDraggableState's callback. One state write per pointer
event, no coroutine spawn during the drag itself.
releaseAnim (Animatable) — driven by a single coroutine in
Modifier.draggable's onDragStopped. Either spring-back to 0 or
slide off-screen + onMinimize.
graphicsLayer reads liveDrag when actively dragging, releaseAnim
otherwise — a single Boolean gate.
Bonus: dismiss now SLIDES the page off-screen before popping nav,
instead of cutting. tween(220ms, FastOutLinearInEasing). Spring-back
on a short drag uses MediumBouncy/MediumLow for a real spring feel
instead of a hard snap. Fling-velocity threshold (600dp/s) also
counts — flick-down past 600dp/s dismisses even if the drag distance
was short.
Two follow-ups on vc=29 (the cutout fix is bundled in too — vc=29
never shipped standalone):
Minibar
Was rendering UNDER the system nav buttons / gesture pill because
StrawActivity uses enableEdgeToEdge() and the minibar was aligned
BottomCenter with no inset awareness. Added
navigationBarsPadding() to the MinibarOverlay Column so it lifts
above the system bar in both gesture-nav and 3-button-nav modes.
Icon
v1 had two overlapping shapes (tilted lime parallelogram + white
play triangle) that fought each other and read as visual noise at
launcher size. Replaced with a single bold white play triangle on
the deep-green (#166534) background — one strong silhouette,
reads at any size, says "video app" without ceremony. PNG fallbacks
re-rendered at all five mipmap densities.
In portrait fullscreen, the camera notch / cutout was eating the top
overlay buttons — SB pill, Speed, Headphones, Videocam, Share, PiP,
Minimize. Tappable area sat under the cutout shadow; presses dropped.
Wrapped the overlay layer (SB pill + control Row) in a Box with
windowInsetsPadding(WindowInsets.safeDrawing). safeDrawing is the
union of system bars + display cutouts + IME, so this single modifier
covers both portrait (notch at top) and landscape (cutout at side)
without per-orientation logic. The PlayerView itself still uses
fillMaxSize with no inset padding — video stays full-bleed and reads
as immersive; only the touch-targets respect the safe area.
Three layout/playback fixes on vc=26-27 feedback plus the long-deferred
app icon.
Layout — VideoDetailScreen
Player surface now fills the screen width (no 16dp side gutters).
Outer Column dropped its 16dp padding; the player Box hangs off the
full width with no rounded corners — NewPipe/YouTube look. Everything
below (title, chips, button row, description, related list) goes back
into an inner Column with 16dp horizontal + 12dp vertical padding so
the body still reads correctly.
Bottom inset: Spacer(windowInsetsBottomHeight(WindowInsets.navigationBars))
appended at the end of the scrollable column. Last related video can
scroll up past the gesture pill / 3-button nav instead of being
obscured by it. (Plain navigationBarsPadding would have pushed the
whole surface up and left a dead band.)
Black-video fix
vc=27's Background button disabled the video track on the controller
via setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) and that override
is sticky. Returning to a video left it audio-only with a black
surface. Added a LaunchedEffect(controller, streamUrl) that resets
TrackSelectionParameters to defaults on every entry into detail — if
the user opened a video page, they want video. The audio-only
fullscreen toggle and the Background button still set the override
for the duration of that session; they just no longer leak.
App icon
Replaced the Android default placeholder (sym_def_app_icon, which
fdroid was failing to render anyway) with a proper adaptive icon:
Background: #166534 deep green (sulkta.com brand)
Foreground: tilted lime parallelogram + white play triangle
(literal "straw" nod + video-app affordance)
Adaptive XML in mipmap-anydpi-v26/. PNG fallbacks rendered via
rsvg-convert at all five mipmap densities (mdpi/hdpi/xhdpi/xxhdpi/
xxxhdpi) for pre-API-26 devices. Manifest now points at
@mipmap/ic_launcher and @mipmap/ic_launcher_round.
Three pieces of feedback on vc=26, fixed in one pass:
(1) Swipe-to-minimize was on the fullscreen player, where it fought
PlayerView's own touch handling and felt janky. Moved the gesture to
the VideoDetailScreen — the same place YouTube/NewPipe put it. The
drag handle is the inline-player surface itself (the 16:9 box at
top); outside that, the description scrolls normally. The whole
page translates with the finger via graphicsLayer + an alpha/scale
fade so the motion reads as "the video is being tucked away" rather
than the old jump-and-snap. Threshold 140dp → onMinimize.
Fullscreen keeps the down-arrow button for minimize; the
drag-to-dismiss path is gone from PlayerScreen entirely (along with
its Animatable + density imports). Whichever surface is visible has
exactly one minimize affordance.
(2) The minibar was always visible on every non-Player screen, which
meant it stacked under the VideoDetail page even though the inline
player is already there. Updated visibility predicate to also hide
on VideoDetail — the minibar is now strictly the take-over UI for
when you leave the video page. Tapping the minibar pushes back to
VideoDetail (not fullscreen) so the mental model is symmetrical:
swipe-down to leave, tap to come back.
(3) Two new buttons on the VideoDetail action row:
Background — disables the video track on the controller and
pops out of detail. The foreground service keeps audio going;
the minibar appears on whatever screen you land on. Pre-checks
that the controller is actually playing this video before
leaving — otherwise the minibar would dismiss into empty.
Popout — enters PiP via the activity, same code path as the
fullscreen overlay button. Same controller pre-check.
Nav helper: added Navigator.resetTo(Screen) so that minimize from
a deep-link entry (where VideoDetail is the only stack item) drops
the user on Home rather than no-op'ing.
Color palette pulled directly from sulkta.com's stylesheet — the same
greens used on the website now drive the app theme:
#166534 deep green (light-theme primary, top app bar background)
#4ade80 bright lime (dark-theme primary, accents in dark mode)
#86efac light green (primaryContainer in light theme)
#e8f5e8 pale green (secondary container tint)
#d97706 amber accent (tertiary)
#374137 olive gray (secondary on light, container on dark)
Replaces the made-up forest palette from vc=23 with the real Sulkta
brand. Same M3 tonal-role mapping so derived surfaces stay consistent.
TopAppBar redone NewPipe-style: solid deep-green bar with white
"straw" title, white hamburger + search icons. Clear bold header
instead of the previous white-with-a-pill-underneath layout.
Material Icons swapped in everywhere we had emoji:
drawer Person / History / PlaylistPlay / Download / Settings
minibar PlayArrow / Pause / Close
fullscreen Speed / Headphones / Videocam / Share /
PictureInPictureAlt / KeyboardArrowDown
Pulled in material-icons-extended (4 MB APK growth, all icons).
Consistent renders across vendors; no more emoji font fallback drift.
FeedRow gets a NewPipe-style duration pill burned into the bottom-right
of every thumbnail (mm:ss / h:mm:ss). Live streams / mixes with no
duration leave it off.
Audit deferred-MED items addressed:
MED-6: dropped the PlayerService STATE_ENDED auto-stop. Service
shutdown is now driven only by onTaskRemoved + the minibar's ×.
Removes the implicit "we'll never queue" assumption and is correct
for a future autoplay/queue feature.
LOW-7: DownloadsScreen adaptive poll — 1s while a download is
active, 5s when idle. No more wasted DB queries when nothing
is running.
Opus max-effort audit of the vc=23 post-MediaController-unification
codebase surfaced two CRITs, both in my own recent code.
CRIT-1 + 1b: inline-position-threading band-aid deleted. After the V-2
controller unification, seeking the live controller to its own
currentPosition was always 0-500ms backwards — every inline-to-
fullscreen and minibar-expand handoff jerked playback backward. The
whole `inlinePositionMs` / `onPositionChanged` / `startPositionMs` /
`seekTo` chain is gone. The controller is one player; no handoff
needed.
CRIT-2: PlayerLeaveHandler removed. The registry was fully orphaned —
nothing ever assigned to handler. Media3 handles HOME-to-background
natively via the foreground service. Dropped the file, the
onUserLeaveHint override, and the import.
HIGH-1 + 5: PlayerViewModel collapsed into VideoDetailViewModel. Both
fetched the same streamInfo for the same URL; PlayerScreen used to
spin up two VMs to lift uploader + thumbnail from one and stream URLs
from the other. One VM now exposes both `detail` and `resolved`. Drops
a redundant network fetch and the double-spinner UX on PlayerScreen.
HIGH-2: AndroidView { PlayerView } in PlayerScreen + InlinePlayer now
has onRelease { it.player = null } so PlayerView surfaces stop
retaining the controller after the composable leaves composition.
HIGH-3: SubscriptionFeedViewModel switched to a per-channel cache.
Each channel's entries refresh on their own TTL — adding one new
subscription no longer invalidates the other 49. Failed/timed-out
channel fetches leave the prior cache entry intact instead of
blanking the feed for that channel.
HIGH-4: onNewIntent override added. singleTask was silently dropping
shared-from-Chrome YT URLs whenever Straw was already running. New
intents now feed pendingDeepLink which the Compose tree drains into
Screen.VideoDetail.
MED-3, MED-8, MED-10, LOW batch: PlayerView control-overlay overlap
fixed by going through one strategy; SearchViewModel.recordSearch
moved into the success branch so errored queries don't pollute recent
searches; Downloader's host whitelist tightened to *.googlevideo.com
only; SubscriptionsStore.clear + HistoryStore.clearWatches/Searches
now use updateAndGet for atomic clear consistent with the other
writers; phase/path/audit-ticket markers stripped from comments
(kept the technical commentary, dropped sprint tags); 4x duplicated
Color(0xCC222222) overlay color extracted to OverlayChromeColor named
constant in StrawTheme; HtmlText + StrawActivity NewPipeExtractor
references replaced with the current extractor.
Net: ~80 LOC deleted overall (the position-threading + handler
registry + duplicate VM more than offset the cache + onNewIntent
additions).
Settings screen now has an Import section. Picks a NewPipeData-*.zip or TubularData-*.zip via the SAF, extracts newpipe.db + preferences.json, and walks the Room SQLite schema (subscriptions / playlists / playlist_stream_join / streams / search_history / stream_history / stream_state). YouTube only — service_id=0 filter drops SoundCloud/PeerTube/etc.
Imported on smoke: 26 subs, 1 playlist with 10 items, 50/11402 watch history (capped by HistoryStore MAX_WATCHES), 20/2242 searches (MAX_SEARCHES), 8 settings keys (SponsorBlock category toggles + default resolution). Resume positions counted (10918) but not yet persisted — Straw has no resume-store yet.
Real MediaController/MediaSessionService unification: a single ExoPlayer
owned by PlaybackService, every UI surface is a MediaController client.
Playback never restarts on screen transitions. Drops the per-screen
ExoPlayer instances; drops the EXTRA_URL Intent-based handoff from vc=21.
Minibar overlay: persistent strip pinned to the bottom of every
non-Player screen whenever something is loaded. Tap to expand to
fullscreen, x to stop and clear, play/pause toggles. Drag-down on the
fullscreen player or the down-chevron overlay button minimizes into the
minibar. Single source of truth for what is playing is NowPlaying — a
process-wide StateFlow refreshed by whichever surface calls
setPlayingFrom.
Custom MediaSource.Factory in the service routes DASH/HLS/progressive
by MIME, and merges video+audio progressives via a side-channel
EXTRA_AUDIO_URL bundle on the MediaItem. SponsorBlock skip loop is now
activity-scoped, hoisted out of PlayerScreen, so segments are skipped in
minibar mode too.
Downloads tab wired into the drawer. Reads DownloadManager every
second, shows status + progress, tap to open, x to remove.
Theme: forest-green primary palette replaces the M3 default lavender /
NewPipe red. Modern, clean, distinct.
vc=20 fixed channel videos but left two player rough edges that Cobb
called out on the phone:
* Tapping 🎧 (background audio) restarted the stream from the
beginning instead of picking up where the foreground player was.
* Pressing HOME on the player auto-entered Picture-in-Picture; what
Cobb wants is seamless background audio.
Both paths now share one handoff: capture exoPlayer.currentPosition,
stop the activity player, start PlaybackService with EXTRA_POSITION_MS,
and seekTo(position) on setMediaItem. Verified on emulator: 🎧 tap from
~34s in-track resumes service at position=34821ms.
HOME-on-player triggers the same path via StrawActivity.onUserLeaveHint
→ PlayerLeaveHandler.handler (a tiny registry the active PlayerScreen
registers in a DisposableEffect; cleared on dispose so the hook is a
no-op anywhere else in the app). The previous DisposableEffect that
called setAutoEnterEnabled(true) is gone; manual PiP via the ⊟ overlay
button stays — that one is still useful and user-triggered.
Also fixes a latent IllegalStateException that the new HOME path
exposed: PlaybackService and PlayerScreen both built MediaSession with
the default empty ID, which the system rejects when both live in the
same process. Service now sets .setId("straw-bg") so the two
sessions can coexist during the brief activity-vs-service overlap
during handoff.
Smoke (emulator 1440x3040):
* Subscriptions feed renders (vc=20 fix carries over).
* 🎧 from ~34s in NCS / Different Heaven → service playing
position=34821ms, no session-ID crash.
* HOME from PlayerScreen → focus moves to NexusLauncher,
PlaybackService running with isForeground=true,
pictureInPictureParams=null on the activity (no PiP).
vc=19 shipped with empty subscription feeds because
strawcore-core's channel_info was parsing the wrong tab + the
wrong renderer type.
strawcore-core e6fbbb7 fixes both — second-browse to the Videos
tab + parse lockupViewModel. This bump pulls that in.
NCS Spektrem + Rick Astley both play through Rust → ExoPlayer h264
MediaCodec on android-emulator. 4s frame-diff verified, zero
PlaybackException. Phase 7 + Phase 8 of the NPE port arc done.
Path C (rustypipe iOS-client extractor) shipped on vc=16-17 returns
googlevideo URLs that the YT iOS-bound progressive-download path
deliberately caps at ~917 KiB end byte. Any seek past that returns 403,
making non-HLS videos unplayable. ExoPlayer's IosSafeHttpDataSource
chunking workaround in vc=17 doesn't help because the cap is on the
URL itself, not on the chunk size.
Rolling back to the vc=15 (0.1.0-AA) state — NewPipeExtractor — so
Cobb's phone auto-updates back to a working version while the
strawcore/rustypipe extractor strategy is re-thought (need a non-iOS
client that returns reliably-streamable URLs, OR a Lucy-side stitching
proxy that fetches iOS chunks server-side and re-serves DASH/HLS).
Branched from 5b36de888 (the vc=15 commit) — purely a versionCode +
versionName bump, no other changes.
Tap the 16:9 thumbnail box on VideoDetail and the video plays right
there in the card — like YouTube. Uses its own ExoPlayer (released on
nav-back via DisposableEffect) with PlayerView's built-in controls
(play/pause/seek/duration bar).
Top-right ⛶ pill on the inline player jumps to the existing fullscreen
PlayerScreen which still has the full toolset (speed picker, audio-only,
share, PiP, background, SponsorBlock chip). Restarts from 0 on entry —
seek-position handoff between inline + fullscreen is a future refinement.
Inline player state (playing/not-playing) is keyed on streamUrl so
navigating to a different video resets it back to the thumbnail-with-
play-overlay default.
Under the thumbnail: title → uploader → chips → buttons → description →
Recommended → More from <uploader>. The Z2/W2 inverted layout shipped in
Y put discovery cards before the title — bad UX, fixed back to standard.
Inline player coming next.
VideoDetail card order (top → bottom under the player thumbnail):
Recommended → More from <uploader> → Video details
NewPipeExtractor fan-out: streamInfo brings 'related', and a parallel
ChannelInfo+VIDEOS-tab fetch brings 'moreFromChannel' (filtered to drop
the current video). Same data shape as feed/search rows; reuses RelatedRow.
Home top bar gets a YouTube-style search pill in the title slot — tap
takes you to the search screen. The drawer 'Search' entry is gone (the
pill replaces it).
Section header below the top bar — 'Latest from your subs' / 'History' —
makes which view you're on obvious. Empty subs state is friendlier.
Status-bar padding (statusBarsPadding) added to VideoDetailScreen,
SearchScreen, ChannelScreen, SettingsScreen — fixes content rendering
under the Pixel camera cutout in edge-to-edge mode.
Phase U (rustypipe Rust extractor) rolled back. Symptom: black screen
on play, root cause: rustypipe 0.11.4's JS deobfuscator can't parse
current YouTube player.js (YT changed the obfuscation pattern, no
upstream rustypipe release since June 2025). Switching clients
(Web → TV → Android/Ios) didn't help — the deobfuscator init fires
universally.
Kept in place for the future:
- rust/strawcore/ Cargo workspace + UniFFI scaffolding
- crafting-table runtime install (rustup + 4 Android targets +
cargo-ndk + NDK r27c)
- The U-2..U-5 commits in history (re-runnable when rustypipe is
fixed or we fork it).
Restored from commit 9550b207a (v0.1.0-T):
- NewPipe.init() in StrawApp.onCreate
- libs.newpipe.extractor + libs.squareup.okhttp deps
- NewPipeDownloader.kt + Thumbnails.kt
- ViewModels (Search/VideoDetail/Player/Channel/SubscriptionFeed) on
NewPipeExtractor calls
- VideoDetailScreen Download dialog using NewPipe's StreamInfo
Future-direction memo: openclaw-workspace/memory/project_rustypipe_fork.md
— fork plan + revival path for the Rust extractor when we're ready
to maintain it.
Verified working in the Android emulator: dQw4w9WgXcQ plays, ExoPlayer
reports state=PLAYING(3), position advancing, video surface rendering.
Black-screen-on-play bug: rustypipe's default player() uses YT's Web
client, which serves stream URLs that are session/UA-locked. ExoPlayer
fetching with a different UA gets a silent 403 from googlevideo and
renders a black surface.
Fix: pin stream_info() to player_from_clients(id, [Tv, Ios]) — the
TVHTML5 + iOS Innertube clients serve direct-play URLs that work in
any HTTP player. Same trick NewPipe uses. No Apple/iOS code involved
— it's just the API client identifier rustypipe sends to YT.
Also added a Player.Listener in PlayerScreen that Toasts any
PlaybackException (codeName + message) so future stream failures don't
look like silent black screens. Logs to logcat 'StrawPlayer' too.
channel_info(url) UniFFI suspend fn. ChannelViewModel +
SubscriptionFeedViewModel both swap. NewPipeExtractor (Java) is OUT —
zero org.schabi.newpipe classes in the APK now.
Cleanup:
- NewPipeDownloader.kt deleted (was the OkHttp adapter)
- Thumbnails.kt deleted (rustypipe returns full URLs)
- NewPipe.init() dropped from StrawApp.onCreate
- libs.newpipe.extractor removed from build.gradle.kts
- STRAW_USER_AGENT + strawHttpClient() now live in net/Http.kt
- RydClient + SponsorBlockClient + PlayerScreen + PlaybackService all
read from net/Http.kt instead of the extractor package
rustypipe API quirks beat:
- channel_videos(id) is the right method (channel() doesn't exist)
- ChannelInfo struct = basic metadata; Channel<T> wrapper carries
name/avatar/banner + .content is the paginator of videos
- description is String (not Option), subscriber_count is Option<u64>
End state: strawApp Kotlin is ~UI + thin glue to strawcore. The Rust
core handles search / streamInfo / channel / channel_videos via UniFFI
suspend fns. Tokio + reqwest + rustls + rquickjs all packed in
libstrawcore.so (~6MB per ABI). APK 40MB total.
stream_info(url) UniFFI suspend fn replaces NewPipeExtractor's
StreamInfo.getInfo() for both VideoDetailViewModel and PlayerViewModel.
One Rust round-trip drives the detail screen render AND the player's
resolve(). The VideoDetailUiState.info field cached on detail load is
reused by the Download dialog so we don't refetch.
Deferred to U-3.5:
- like_count (rustypipe's player() doesn't surface engagement data;
a separate query is needed)
- related (player() doesn't include 'up next'; comes from a separate
endpoint). Kotlin gets empty list for now — RelatedRow handles it.
Type quirks vs my initial guesses (caught by cargo check):
- details.duration is u32, not Option<u32>
- channel is split into channel_id + channel_name, not a struct
- like_count doesn't exist at this query depth
- VideoFormat::Webm (lowercase mb), VideoCodec::Avc1 (not H264)
- video_only is a separate vec (video_only_streams), not a bool flag
User-visible:
- BUG: 'clicking a second item went back to the first video' — VideoDetailViewModel
guard was short-circuiting on Activity-scoped ViewModel reuse. Now tracks
loadedUrl and only skips when the requested URL matches.
- BUG: PiP / window mode now auto-enters on Home gesture (Android 12+ via
setAutoEnterEnabled). Manual PiP button reports failure cause via Toast.
- HOME REDESIGN: replaced 3-tab bottom nav with hamburger ModalNavigationDrawer.
Default view = sub feed. Drawer items = Subscriptions / History / Search /
Settings. Top-left hamburger like normal Android apps.
Audit pass #2 (Opus max-effort) — CRIT + HIGH fixes shipped:
- CRIT-1: PlaybackService now calls startForeground() inside onStartCommand
with a media-playback notification + channel. Pre-fix could throw
ForegroundServiceDidNotStartInTimeException on Android 12+ and crash-kill.
- CRIT-2: AndroidManifest service exported=false. Previously any installed
app could craft an Intent and drive playback from attacker URLs.
- HIGH-1: 🎧 background handoff stops the activity player before starting
the service so we don't dual-host two ExoPlayers + MediaSessions.
- HIGH-2: onStartCommand returns START_NOT_STICKY and tears down on null
intent; no more crash-restart-crash loop after OS kills.
- HIGH-3: stop service on STATE_ENDED / STATE_IDLE via Player.Listener.
onTaskRemoved checks playbackState properly so we don't hold WAKE_LOCK
forever after a video ends in background.
- HIGH-4: Downloader validates scheme=https + googlevideo/youtube host
before handing the URL to DownloadManager.
- HIGH-5: filename sanitization extended to ASCII control chars, DEL,
Unicode bidi-override block, leading-dot, trailing whitespace.
- HIGH-6: SubscriptionFeedViewModel cancels prior in-flight refresh,
caps parallelism at 8 via Semaphore, applies 15s per-channel timeout.
- HIGH-7: sub feed error banner now shows above cached items when refresh
fails (previously hidden, looked indistinguishable from success).
- HIGH-8: PlayerViewModel falls back to lowest-available stream when no
stream is under the max-resolution ceiling (was: silent black screen).
- HIGH-9: network_security_config explicit cleartextTrafficPermitted='false'
on the RYD domain-config block (doesn't inherit from base-config).
- MED-1: PlaybackService.onDestroy nulls field before releasing session to
close a race with onGetSession during teardown.
- MED-6: Downloader catches enqueue exceptions, returns -1L, caller toasts
'download refused (bad URL)' instead of crashing.
Deferred (audit said 'can wait'): MED-2..5, MED-7..11, HIGH-10 UX consistency.
Phase P — bottom navigation:
- StrawHome restructured as a Scaffold with Material3 NavigationBar.
- Three tabs: Home (search + last 10 watches), Library (full watch
history with count), Subs (channel chips + aggregated feed).
Phase Q — subscription feed:
- New SubscriptionFeedViewModel fans out per-channel ChannelInfo +
ChannelTabs.VIDEOS fetches in parallel via async/awaitAll.
- Each channel contributes top 5; merged across all subs, capped at
200, sorted by view count as a soft-recency proxy (extractor doesn't
reliably surface upload timestamps).
- 10-minute cache TTL avoids hammering YT on tab re-entry.
- Subs tab renders the feed below the avatar row with a Refresh button.
Phase R — download:
- Download button on VideoDetail (next to Play / Share). Pops a tiny
dialog: Audio (best audioStream) or Video (best videoStream/
videoOnly fallback).
- Uses Android's DownloadManager — saves into app-private external
files dir (Android/data/com.sulkta.straw.debug/files/Movies/<kind>/).
Notification + progress for free. No WRITE_EXTERNAL_STORAGE needed.
- Filenames sanitized (no /:*?\"<>| chars), capped at 120 chars.
Phase S — background audio:
- New "Background" overlay button (🎧) on the player. Tap to pause the
activity player and start PlaybackService with the audio URL.
- PlaybackService is a Media3 MediaSessionService with its own ExoPlayer
configured with our custom DataSource.Factory (User-Agent set, cross-
protocol redirects). Foreground service + media notification.
- Audio survives activity death — swipe the app out of recents, audio
keeps playing. Stop via notification or open-the-app-and-tap-stop.
- onTaskRemoved keeps the service alive iff something is playing.
Versions shipped: P+Q as vc=4, R as vc=5, S as vc=6. Each landed in the
F-Droid repo for the day-by-day refresh path.
Day-N+ ideas: real MediaController unification (single Player for both
foreground + background paths), MergingMediaSource on the service side
for high-res YT videos, real upload-timestamp sort for feed once the
extractor exposes it consistently, queue/playlist.
VideoDetail screen:
- New "Related" section at the bottom — pulls
StreamInfo.relatedItems, filters to StreamInfoItem, renders as
inline thumbnail rows. Tap → push another VideoDetail. Up to 20
items shown. Each row uses bestThumbnail() for hi-res.
Settings screen + PlayerViewModel:
- New "Playback" section with a Max-Resolution picker:
Auto / 1080p / 720p / 480p / 360p / 144p. Persisted to
SharedPreferences (KEY_MAX_RES) via SettingsStore.maxResolution
StateFlow.
- PlayerViewModel.resolve filters videoStreams + videoOnlyStreams by
the ceiling before picking the max-bitrate one. Auto (Int.MAX_VALUE)
is unchanged behavior. Choosing 720p caps the renderer so 1080p/4K
streams are skipped — saves bandwidth on mobile + helps low-end
decoders.
Phase P next ideas: bottom navigation tabs (Home / Subs feed /
Library), Download (audio + video), the MediaSessionService refactor
for true background audio after activity death.