- Audio-only toggle no longer drops the max-resolution cap: both the
fullscreen button (PlayerScreen) and the detail Audio pill (VideoDetailBody)
rebuilt TrackSelectionParameters from a fresh Builder, wiping the data-saver
ceiling. Now buildUpon() the existing params so the cap survives. (H2)
- Subscriptions Refresh button no longer sticks at "..." forever on a warm
restart within the cache TTL: refreshIfStale clears the initial loading
seed when it decides nothing needs refreshing. (M2)
- Search + Channel result lists get a stable item key (video url) so paging /
shorts-filtering stops re-binding rows to new data (re-triggered thumbnail
loads, scroll shift). (M3, M4)
- IosSafeHttpDataSource: the unknown-length (LENGTH_UNSET) chunk path rolls
forward to the next Range chunk at inner-EOF instead of re-reading the
exhausted source forever (was truncating playback to the first chunk). (M5)
- strawcore channel_feed_rss propagates the real failure (network/HTTP/parse)
instead of collapsing every error to an empty list, so a broken fetch is
distinguishable from "no new videos" (subscription_feed keeps its per-channel
tolerance for fan-out). (M6)
- Feed recency: a clock-skewed future upload emits "0 seconds ago" (parses to
top) instead of "just now" (which Kotlin's recency parser couldn't read, so
the item sank to the bottom). (L4)
Deferred to a follow-up: M1 (bg-refresh cache-key mismatch — needs a worker
redesign) + M7 (build config-cache wiring). Verified: cargo check/clippy +
full Android compileDebugKotlin green.
- Thumbnails + channel icons stay cached: pin an explicit 256MB Coil disk
cache + sized memory cache via SingletonImageLoader.Factory. Coil's
default disk cap is 2% of the device's free space, so on a storage-tight
phone the subs feed (most image-heavy screen) thrashed it and
re-downloaded thumbnails on every visit.
- SponsorBlock + Return-YouTube-Dislike clients moved Kotlin -> Rust
(strawcore net.rs: fetchSponsorSegments / fetchRydVotes). SponsorBlock
keeps its privacy-preserving SHA-256 hash-prefix lookup. Kotlin is now a
thin shim mapping the FFI records onto the SbSegment/RydVotes domain
types; behavior identical. Migration #2 of "all backend -> Rust".
- Fix crash: extract_channel_id sliced the channel URL by a length derived
from a lowercased copy of itself; to_lowercase() can change byte length
on non-ASCII, so a non-ASCII URL tail could panic across the FFI and
abort the app on a feed refresh. Now matches the prefix case-insensitively
against the original with length + char-boundary guards.
- Fix autoplay hijack: advancing to the next video resolves over ~500ms; if
you manually start a different video meanwhile, autoplay would replace
your choice with the stale next-up. Added a staleness fence.
Verified: cargo check/test/clippy on the wrapper, full Android
compileDebugKotlin green, adversarial FFI pre-push audit passed.
Two changes:
1. Launcher name is now just 'Straw', not 'Straw debug' — past the
debug-branding phase. Kept the .debug applicationId suffix (package
stays com.sulkta.straw.debug) on purpose so fdroid updates install in
place and the in-app auto-updater keeps working; dropping the suffix
would change the package id and force a reinstall that wipes everyone's
subs/history. That's a separate, deliberate release-track cutover.
2. Stream-selection logic moved out of Kotlin (resolveStreamPlayback) into
the Rust strawcore wrapper as resolve_playback(StreamInfo, max_height)
-> ResolvedStreams. The app keeps a thin shim that supplies the
resolution cap (Settings.maxResolution) and attaches SponsorBlock
segments. Byte-for-byte behavior parity with the old Kotlin picker:
highest-bitrate stream at/under the cap, lowest-height fallback when
nothing fits, first-element-wins on ties (matching Kotlin
maxByOrNull/minByOrNull, not Rust's last-on-ties max_by_key), and
isNotBlank() handling for the DASH/HLS URLs. First step of moving all
backend logic to Rust; the picking lives at the FFI boundary because it
depends on an app setting, keeping strawcore-core a pure extractor.
Wrapper cargo check + clippy clean (no new warnings); FFI surface adds
ResolvedStreams + resolvePlayback, bindings regen at build.
The inline player's auto-resolve→play LaunchedEffect read the shared
activity-scoped VideoDetailViewModel's `resolved` stream without checking
it belonged to the newly-opened video. Right after a swap (e.g. pick
another video from the browse screen while one sits in the minibar), the
VM still holds the previous video's resolved URLs for one composition
frame until vm.load() nulls them — so setPlayingFrom fired with
streamUrl=NEW but resolved=OLD: NowPlaying.claim won under the new url
(title/details/minibar flipped to NEW) while the controller kept
streaming OLD, and the correct re-fire with NEW's resolved was then
swallowed by the 'already playing this url' short-circuit.
Restore the loadedUrl fence (the same guard VideoDetailBody and every
ViewModel already use) that the vc=75 expandable-player rearchitect
dropped when the inline resolve→play wiring moved out of VideoDetailScreen.
enrich_feed_item now calls the new strawcore stream_metadata() path (Android
/player + videoDetails read only) instead of the full stream_info. The full
path ran the JS sig/nsig deobf, an extra WEB /player metadata round-trip, the
iOS client, and stream/manifest/caption extraction — then kept only view_count
+ duration_seconds. Those two come from the same videoDetails the lightweight
path reads (populate_microformat never touches them), so the values are
identical; the feed just stops paying for the discarded work — ~one heavy
round-trip dropped per enriched item per refresh.
FFI surface (enrichFeedItem -> EnrichedFeedMetadata) unchanged. Needs
strawcore 30f24d2 (pushed first; CI clones strawcore main).
Search: the reactive cache-preview filter no longer runs on the main
thread on every keystroke. It walked the whole cached-results pool
(thousands of items on a heavy user) inline; now each keystroke
debounces ~150ms and the scan runs on Dispatchers.Default. A submit
cancels the pending preview so a late scan can't clobber live results.
Feed: mergeFromCache memoizes the relative-upload-date parse by string,
so the recency regex runs once per distinct "N days ago" value instead
of once per item (~3000 per merge on a 200-sub feed) — across
hydration, every refresh, and each background-enrichment emit.
No behavior change.
Picks up strawcore 91d4824: streamingData + format objects borrowed
instead of deep-cloned per video open; channel Home/Videos tabs fetched
concurrently (one round-trip, not two); response bodies decoded in place
on the valid-UTF-8 path. No behavior change — internal allocation +
latency wins only.
- PlaybackService: the pauseOnHeadphoneDisconnect settings-watcher collector
ran on globalScope (Dispatchers.IO) and called setHandleAudioBecomingNoisy
on the ExoPlayer, which is thread-affine to the Main thread it was built on
→ latent IllegalStateException('Player accessed on the wrong thread') that
fires every service start (the StateFlow emits on first subscription). Run
the collector on Dispatchers.Main (mirrors resumePollJob).
- SearchViewModel: recordSearch (json-encode + SharedPrefs write) was on Main;
wrap in withContext(Dispatchers.IO).
Both adversarially verified in the multi-agent perf audit (pass 2).
From the multi-agent perf audit (adversarially verified), the two app-side wins:
- 1.1 Preserve the max-resolution cap on autoplay-next. The enter-video
trackSelectionParameters reset built from a blank default, silently
dropping the data-saver ceiling every URL change so autoplay streamed
uncapped. Now: re-enable the video track via buildUpon + reassert
applyMaxResolutionCap().
- 2.1 VideoDetailBody verticalScroll Column -> LazyColumn. Related +
more-from-channel rows recycle and defer each AsyncImage decode + the
two ThumbnailProgress flow collectors until scrolled into view (was ~40
decodes + ~80 collectors eager on every open). Namespaced item keys
(rel:/mfc:) so a url in both lists doesn't crash the list; kept take(20);
dialogs hoisted out of the lazy content.
The remaining sluggishness was scaling a live-playing TextureView through
the morph's graphicsLayer every frame. Now the minibar + the whole
collapse/expand morph render the video's static poster (AsyncImage); the
live PlayerView only mounts once settled fully expanded. Audio is
unaffected — it's owned by the foreground service, never stops.
- The detail body's alpha fade was rendering the whole scroll subtree to
an offscreen buffer every frame (CompositingStrategy.Auto goes offscreen
when alpha<1). Switch to ModulateAlpha — the body's rows don't overlap,
so the per-draw-op fade is correct and skips the buffer. Main fix.
- Replace the 300ms FastOutSlowIn tween (slow ramp at both ends) with a
no-bounce StiffnessMedium spring — distance-adaptive, reads as snappy.
Gradle build is green (uses JAVA_HOME directly), but the signer-verify
step calls apksigner — a shell wrapper that needs 'java' on PATH. The
straw-build image sets JAVA_HOME without adding its bin to PATH for run
steps, so apksigner died with 'exec: java: not found'. Export it.
The runner's default shell for run: steps is dash, which errors
'Illegal option -o pipefail' the moment a step runs 'set -euo pipefail'
(the clone step, plus the pre-existing Verify + publish steps). Set
defaults.run.shell: bash for the job; the straw-build image ships bash.
The build-and-publish job runs in the straw-build container, which ships
the Android + Rust toolchain but NOT node. actions/checkout@v4 is a Node
action, so it died with 'exec: "node": not found' before any source was
checked out — every build run since the workflow landed was red for this,
not the registry-pull theory.
- Replace both actions/checkout@v4 steps with a plain 'git clone' (git is
in the image, both repos are public). Also sidesteps the runner's flaky
data.forgejo.org action fetch. strawcore stays a sibling of straw for
the rust/strawcore path dependency.
- Pick apksigner from whatever build-tools the image actually ships (36),
not the hardcoded 34.0.0 that doesn't exist in it.
Build + publish prereqs verified present: docker CLI in image, runner
docker_host=automount + --group-add, and the STRAW_SIGNING_KEYSTORE_B64 /
STRAW_FDROID_RACKHAM_KEY secrets are set.
Replace the separate Screen.VideoDetail page + MinibarOverlay with one
ExpandablePlayer container that morphs continuously between the full
video page and the bottom minibar, in both directions. The old flow just
made the minibar appear/vanish; this is a true shared-element transition.
- One fraction (0=minibar, 1=full page) drives a graphicsLayer
scale+translate on a single mounted TextureView PlayerView. The
transform runs in the render phase (reads the Animatable inside the
layer block) so the morph is smooth without recomposing the detail
body, and the same video surface stays live across the whole range.
- 100dp collapsed player is 16:9, same as expanded, so the morph is a
pure uniform scale (no aspect distortion).
- Opening a video sets OpenVideo + expands instead of pushing a screen;
the browse screen stays underneath so collapsing returns you there.
- New OpenVideo singleton (open video) distinct from NowPlaying (playing
video); the two are kept in sync while collapsed so autoplay-next
doesn't leave the open page stale.
- VideoDetailBody extracted from VideoDetailScreen; the inline player
surface + resolve/play wiring became InlinePlayerSurface inside
ExpandablePlayer. VideoDetailScreen + MinibarOverlay deleted.
- Back: fullscreen pops, then expanded collapses, then browse stack.
- Unchanged: shared controller, NowPlaying, setPlayingFrom, SponsorBlock,
autoplay-next, PiP, background audio, and true-fullscreen Player (⛶).
Item 1 (partial): the minibar was tap-to-expand only — added an upward-drag gesture that expands back to the full player when released past a small threshold (Cobb: couldn't swipe the minibar back up). The continuous collapse-into-the-bar shared-element animation is deferred — needs on-device iteration. vc=74 ships items 2-5 fully + this.
At STATE_ENDED the finished video is still loaded (mediaItemCount==1), so enqueueLast appended the autoplay candidate at index 1 and the ENDED player never advanced to it — autoplay did nothing. Switch tryAutoplay to setPlayingFrom, which replaces + prepares + plays the next video. The old comment's 'queue is empty' assumption was the root error.
Item 2: thumbnail progress bar now stays full on watched videos — falls back to watch-history when the live resume point is gone (was vanishing on finished videos). A live resume entry still wins for mid-watch progress. Item 3: HistoryStore tracks a per-video playCount (increments each watch, carried forward atomically in recordWatch; defaults 1 for pre-vc74 entries). VideoDetail shows 'Watched N times' under the view count.
hide-watched was session-only remember state in StrawHome — reset to OFF on every cold start. Back it with SettingsStore (SharedPreferences), mirroring hideShorts: new hideWatched StateFlow + setHideWatched(). StrawHome reads/writes Settings.
Straw runs on the strawcore Rust pipeline and ships only :strawApp — it is not NewPipe and uses none of their app code. Removes the 12M org.schabi.newpipe :app module (the fork base) and the unused NewPipe-origin KMP :desktopApp/:shared scaffold. settings.gradle now includes only :strawApp; also drops the NewPipe SPDX header + the NewPipeExtractor includeBuild stub. This also kills the recurring config-time git failure from app/build.gradle.kts.
Inline player → TextureView (XML surface_type) so the swipe-down-to-minimize drag follows the Compose graphicsLayer transform instead of the SurfaceView lagging behind (the stutter). Description folded into a collapsible Details section, collapsed by default, above recommendations. Action buttons restyled into one horizontally-scrollable row of uniform tonal icon pills; dropped the redundant Play button (inline player + fullscreen pill cover it).
Build the Straw APK in CI from a dedicated, ephemeral build container
(git.sulkta.com/sulkta-infra/straw-build — Android SDK/NDK + Rust +
cargo-ndk, see ci/Dockerfile) instead of the persistent crafting-table.
The runner spins the container up per job and tears it down after.
On push to main (after the build passes + the signer fingerprint is
verified against the canonical key) it publishes to fdroid.sulkta.com:
APK into the Lucy repo + index re-sign via the host docker socket, then
the signed repo streamed to Rackham web168 over a scoped forced-command
deploy key. Keystore + deploy key are Forgejo repo secrets.
Build steps run under `ionice -c3 nice` so they can't I/O-starve the live
DBs on Lucy.
Wire an env-driven signingConfig so CI/release builds reuse ONE keystore
instead of Gradle's per-machine auto-generated ~/.android/debug.keystore
— a fresh CI container would otherwise mint a different key and break
in-place fdroid updates for everyone. STRAW_KEYSTORE_FILE (+ _PASS /
_ALIAS / KEY_PASS) point at the canonical key; unset → default debug
signing so local dev needs no setup. The key is the same androiddebugkey
(SHA1 BB:9C:A9:6B…) that signed vc 15→71, now also vaulted.
vc 71 → 72 for the channel/search infinite-scroll pagination fix.
Wire the new strawcore continuation fetchers through UniFFI and add
load-more-on-scroll to the Channel and Search screens — previously both
loaded only page 1 and stopped.
FFI (rust/strawcore): search() now returns Page{items, continuation};
channelInfo carries videos_continuation; new searchContinuation() and
channelVideosContinuation() suspend funs map the core ContinuationPage.
Channel + Search ViewModels: loadMore() fetches the next page, dedups by
url, advances the token, and stops when the token runs out or a page
yields zero net-new items (guards a looping continuation). Result-set
swaps (channel switch / new submit / cache preview) cancel the in-flight
page, and a token fence inside the state update prevents a stale page
being spliced into a replaced list. Screens add a near-end LazyColumn
trigger (rememberLazyListState + derivedStateOf) and a footer spinner.
Picks up two strawcore-core commits with no wrapper-side code change:
* 7d2e4c5 — drop unused Phase-1 scaffolding (page, metainfo, service)
* bfd06d1 — cap attribution_link recursion + Downloader body size
Both are surfaced in the .so embedded in this APK; vc bump is the
mechanism to get them to fdroid clients already on vc=70.
Round-3 audit (on vc=69) caught three real HIGHs plus three worth-fixing
MEDs. All in the same family: 'isAllowedYtUrl gate missing at consumer
side.'
HIGH-1 — ChannelScreen ignored the round-2 loadedUrl gate
Round 2 added loadedUrl to ChannelUiState but ChannelScreen never
read it. Channel A → back → Channel B showed A's data for one frame
before vm.load(B) cleared it. Same shape as the VideoDetailScreen
fix; matching gate added.
HIGH-2 — PlaybackService.tryAutoplay missing allowlist
Both the SameChannel path (channelInfo(uploaderUrl)) and the
candidate-resolve path (streamInfo(candidateUrl)) hit strawcore
with extractor-derived URLs. Gate added on both call sites, plus
the picker's intermediate uploaderUrl check.
HIGH-3 — VideoActionsSheet.enqueue missing allowlist
Long-press on a poisoned related-card / channel video → 'Add to
queue' invoked uniffi.strawcore.streamInfo on the raw target.url.
Bail with Toast before launching the resolve coroutine.
MED-1 — FeedRefreshWorker doesn't retry on RequiresLogin
reCAPTCHA challenges clear minutes-to-hours later. Treating them
as permanent ate a full refresh cycle. Catch
StrawcoreException.RequiresLogin → Result.retry().
MED-3 — VideoDetail.uploaderUrl persisted raw extractor value
Round-2 added safeFresh for the avatar but the uploaderUrl saved
to VideoDetail.detail was still info.uploaderUrl. NowPlaying →
PlaybackService picked up the raw value. Validate once and persist
the SAFE value so the whole downstream chain inherits it.
MED-4 — enrich-job filter rebuilt Set per iteration
.filter { it.url in channelsSnapshot.map { c -> c.url }.toSet() }
was O(N²). Hoist via mapTo(HashSet()) once.
Bonus sweep — gated two more uniffi.strawcore.* sites the round-3
agent's category prediction caught:
* SubscriptionFeedViewModel.enrichVisibleItems → enrichFeedItem
now skips items failing isAllowedYtUrl.
* PlaybackService autoplay candidate-resolve already covered
under HIGH-2 above.
Round-2 audit caught four real regressions on round-1 fixes plus a
handful of MEDs. This sprint fixes them.
H1 — FeedRefreshWorker exception class
Round 1 wrapped subscriptionFeed in try/catch IOException, but
UniFFI generates StrawcoreException (kotlin.Exception, not
IOException). The retry path was dead code. Catch
StrawcoreException.Network instead — the variant our error.rs
maps NetworkError::Transport into.
H2 — enrichJob terminal emit cancellation race
withContext(Dispatchers.Default) { mergeFromCache(...) } has no
suspension points so a cancel arriving mid-merge isn't observed
until the next suspending call. Without a guard, the non-suspending
_ui.update lands AFTER clearInMemoryCache() and resurrects the
cleared items. Add coroutineContext.ensureActive() after each
withContext hop, before the emit. Applied on both the refresh
terminal emit and the enrich terminal emit.
H6 — enrichVisibleItems shows stale subscriptions
The channelsSnapshot captured at refresh-end is ~2s stale by the
time the enrich terminal emit runs. If the user unsubscribed from
X in that window, X's items still appear on the feed for one
frame. Re-read Subscriptions at the terminal step and intersect
with the snapshot.
R-H3 — extract_channel_id substring match
Round 1 used trimmed_lower.find(prefix) which matches ANY position.
evil.com/?redir=https://www.youtube.com/channel/UCxxx silently
rewrote to the embedded channel ID. strip_prefix() anchors at byte
0. ASCII-only prefix means byte indices align in trimmed_lower vs
trimmed.
R-H2 — String::from_utf8 silent-drop
YouTube ships mojibake titles in the wild. Strict from_utf8
returned None on any bad byte, dropping the entire channel from the
feed with only a quiet None. Switch to from_utf8_lossy — quick-xml
tolerates U+FFFD replacement chars and the per-entry skip-on-empty
handles broken entries.
R-H1 — read_capped_body per-chunk size sanity
HTTP allows arbitrarily large single chunks. Reject any chunk
exceeding the whole body cap before adding it to the buffer, so a
hostile server can't get us to allocate a hyper Bytes larger than
the cap.
M3 — Avatar URL validation
ch.avatar is extractor-emitted; a poisoned channel page could ship
data:image/svg+xml,<svg>...<script> or javascript: URLs. Validate
http(s):// scheme before persisting to Subscriptions and before
surfacing via VideoDetail.uploaderAvatar.
M4 — ChannelViewModel dual loadedUrl
Same shape VideoDetail's round-1 fix declared unsafe. Move
loadedUrl into ChannelUiState, drop the field, use _ui.value
snapshot at top of load() and _ui.value.loadedUrl for the fence.
Rejected-URL path also stamps loadedUrl so the gate is coherent.
Block B — enrichment lifecycle drift:
* SubscriptionFeedViewModel tracks enrichJob, cancelled in refresh
+ clearInMemoryCache so spam-refresh and cache-toggle no longer
leave a globalScope coroutine writing to a destroyed _ui
* Enrich now runs on viewModelScope, channels snapshotted at job
start so the terminal merge doesn't read a stale subs list
* mergeFromCache moved off Main on both the refresh path AND the
init-hydration path — 750-item flatMap+sort+regex no longer
blocks the UI thread
* VideoDetailViewModel dual loadedUrl bookkeeping collapsed to
the UiState field only; the rejected-URL path also stamps
loadedUrl so the gate reads coherently
Block A — auto-update authenticity:
* AppUpdateClient pins the fdroid.sulkta.com leaf SPKI + the
Let's Encrypt E7 intermediate via OkHttp CertificatePinner
* file.name accepted only when matching ^/[A-Za-z0-9._-]+\.apk$
* versionCode clamped to (0, 10_000_000] before we trust the
'update available' notification — a hostile index can no longer
pin us to MAX_VALUE
Block C — captureResumePosition perf:
* ResumePositionsStore.record short-circuits when the existing
entry matches position+duration so the 5s poll's
before !== next guard actually skips the SP write
* JSON encode + SP write off Main via globalScope IO
Block D — Rust feed.rs hardening:
* Shared reqwest Client via OnceLock — 50 channels no longer
pay 50 TLS handshakes
* Response body capped at 2 MiB via bytes_stream — adversarial
feeds can't OOM the JVM
* parse_rss returns partial results on quick-xml errors instead
of nuking everything already parsed
* extract_channel_id widened (m./www./http(s)?/trailing path)
and validates exact 24-char UC<22 base64-ish>
* Skip entries with empty title/published
* iso_to_relative future dates → 'just now' (clock skew
no longer pins items to top)
* civil_to_days year clamp 1970..=2200 before the i64 arithmetic
* Redirect chain capped at 3
* Dropped the broken lexicographic sort on upload_date_relative
* Cap parsed entries at 50 per channel
MED batch:
* ThumbnailProgressOverlay uses derivedStateOf so only rows
whose specific entry changed recompose on the 5s positions tick
* EnrichmentStore.put short-circuits on identical view+duration
so re-enrich within TTL doesn't write SP
* EnrichmentStore.load prunes TTL-expired entries on hydration
* FeedRefreshWorker distinguishes transient (Result.retry) from
parse (Result.success) failures
* WorkManager interval coerceAtLeast(15L) on both schedulers
LazyColumn items() now keyed by url so pagination doesn't re-key every
row from scratch when visibleCount jumps. The displayed page slice is
remembered so SubsPane doesn't reallocate the take() ArrayList on every
recomposition. ThumbnailProgressOverlay switched from
collectAsStateWithLifecycle to plain collectAsState — the lifecycle
wrapper added a DisposableEffect per call site, which adds up across
the ~30 visible rows and was contributing to scroll hitch.
Cobb asked for views + durations back in the subs feed without
giving up the 5-10× RSS speedup vc=56 bought. Hybrid path:
1. Rust wrapper — new enrich_feed_item(video_url) ->
EnrichedFeedMetadata { view_count, duration_seconds }. Thin
wrapper around stream_info that discards the heavy play-URL
payload. Future opt: parse watch-page HTML JSON state directly
to skip JS deobf entirely. ~150 lines of pluck logic, punted.
2. EnrichmentStore — new SharedPreferences-lite store keyed by
videoId, value Enrichment(viewCount, durationSeconds,
fetchedAt). Bound to Settings.cacheTtl for staleness. Hard cap
5000 entries with oldest-eviction.
3. SubscriptionFeedViewModel — after the RSS refresh paints,
enrichVisibleItems() fans out enrichFeedItem for the first 30
items (skipping any already enriched fresh). Bounded at 8 wide
so we don't hammer YT; each call ~500ms full streamInfo so
30 items in ~2s. Runs on StrawApp.globalScope so a
refresh-cancel doesn't kill the in-flight enrichment.
mergeFromCache overlays the enrichment via .withEnrichment()
so RSS rows pick up viewCount + durationSeconds the moment
they land. The Enrichment store's StateFlow.value is read on
every merge call; the enrichment-complete handler triggers a
_ui.update that re-merges.
Net behavior: feed paints instantly from RSS (no view/duration),
~2s later the visible top-N populate with full metadata. Cached
forever (or until TTL/cap). Subsequent opens read straight from
EnrichmentStore.
StrawApp.onCreate inits the new store alongside the existing
SP-backed ones.
vc=64 fixed RelatedRow's empty-metadata bug. ChannelVideoRow and
ResultRow had the same shape problem AND duplicated duration:
- ChannelVideoRow: showed 'N views · 0:42' which doubled with
the VideoThumbnail's bottom-right duration badge. Stripped
the duration text, added uploadDateRelative. Now reads
'N views · 2 days ago' matching YT's channel page format.
- ResultRow: same duplicate-duration. Same fix. Search results
now show 'N views · 2 days ago' under the uploader line.
All four video-row composables (FeedRow, RelatedRow,
ChannelVideoRow, ResultRow) now use the same leading-separator
buildString pattern: 'piece [· piece]*' that gracefully composes
whatever fields are populated. No more empty metadata lines, no
more duplicate duration.
Two issues Cobb caught on the vc=63 walkthrough:
(1) Subscription chip names wrapped to two lines mid-word at 80dp
chip width: 'NoCopyrightS / ounds', 'DEFCONConfe / rence',
'Practica / Engineer...'. Switched to maxLines=1 + ellipsis +
center-align. 'NoCopyrigh…' reads cleaner than the broken wrap.
(2) Related + More-from-channel rows showed an empty metadata
line under the title because the buildString started with
item.uploader (empty for channelInfo-sourced rows — channel
pages omit the uploader name from each card since it's
implicit). Switched to a leading-separator pattern that
gracefully composes whatever pieces are populated:
'uploader · views · date', 'views · date', 'date', etc., and
hides the line entirely when nothing's available. Date was also
never rendered before — channelInfo gives it but RelatedRow
ignored it. Now visible everywhere.
Cobb reproduced 2026-05-26: clicking video B from detail A's related
section opens detail B (title, description correct), minibar/media
notification show A's title, and AUDIO plays A. The bug everyone
was hitting as 'video bugs are back'.
Root cause — VideoDetailViewModel is activity-scoped, so navigating
A→B shows ONE composition frame with the previous video's state
before vm.load(B)'s reset propagates. During that frame:
1. VideoDetailScreen body runs with streamUrl=B but
state.detail=A and state.resolved=A's playback URLs (stale).
2. InlinePlayer is called with title=A, streamUrl=B, resolved=A's.
3. Its LaunchedEffect launches a coroutine. Body is synchronous
(no suspend), runs to completion before cancellation can
interrupt.
4. setPlayingFrom(streamUrl=B, resolved=A's URLs) fires. claim()
succeeds → NowPlaying = {streamUrl=B, title=A's title}.
setMediaItem with A's playback URIs → player loads + plays A.
5. State reset propagates. InlinePlayer disposes.
6. After vm.load completes with B's data, InlinePlayer recomposes
with B's resolved. Its NEW LaunchedEffect fires. The check
'NowPlaying.streamUrl == streamUrl' returns true (because step 4
already stamped streamUrl=B). RETURN EARLY. setPlayingFrom(B)
NEVER fires with the correct B data.
Fix — add a loadedUrl field to VideoDetailUiState that tracks
which streamUrl the current detail/resolved actually belong to.
Gate VideoDetailScreen's player composition on
state.loadedUrl == streamUrl, so the stale-state frame can't fire
setPlayingFrom with mismatched data.
vm.load sets loadedUrl in the initial reset AND the success/error
updates — every state transition carries the URL that owns it.
Opus max-effort audit on vc=53-vc=61 diff caught four interlocked
playback bugs. Cobb's 'video bugs are back' likely lived in the
intersection of #1+#2 — stale auto-resume seek + no recovery path.
BUG-1: setPlayingFrom clamps auto-resume against entry.durationMs.
YouTube can replace a video at the same videoId with a shorter cut
(live->VOD trim, premiere edit). Without the clamp, setMediaItem
seeks past the new end, ExoPlayer fires onPlayerError, NowPlaying
clears, surface locks at thumbnail+spinner. Clamp at lookup uses
the recorded duration with a 5s safety margin; falls back to 0
when out of range.
BUG-2: InlinePlayer adds a Retry button on the playback-error
branch. Tapping it nulls playbackError + bumps a retryVersion that
re-keys the setPlayingFrom LaunchedEffect. Previously the screen
locked into the error message forever (no UI affordance to
re-attempt; LaunchedEffect's keys never changed). Bonus protection:
the manual retry path avoids the infinite-error-loop risk a
NowPlaying-keyed auto-retry would have created.
BUG-4: captureResumePosition now gates strictly on STATE_READY.
STATE_BUFFERING during a fresh setMediaItem reports the PREVIOUS
item's position via currentPosition — the 5s poll was happily
writing A's tail position under B's videoId in that window. Next
auto-resume would drop the user mid-A on a fresh open of B.
BUG-5: onMediaItemTransition falls back to MediaItem.mediaMetadata
when Queue.at(idx) is null. Without the fallback, a Queue/controller
desync would leave NowPlaying stuck on the previous item forever,
freezing controllerOnThisVideo at false and locking the inline
player into thumbnail+spinner on the next screen.
Cobb caught the regression on vc=60: subs feed only showed LTT +
WTYP because vc=56's RSS path emitted raw ISO timestamps in
upload_date_relative, but Kotlin's recencyScore() parser only
understands 'N units ago' format. Every item tied at MIN_VALUE,
sort order went to whichever channel resolved first in the
50-concurrent fan-out — LTT + WTYP just happened to win the race.
Fix in feed.rs: parse the RFC3339 published timestamp, compute
delta from now, format as 'N second/minute/hour/day/week/month/year
ago'. Matches recencyScore's regex exactly. RSS still gives ISO;
we convert at the Rust boundary.
Standalone RFC3339 parser (no chrono dep) — Howard Hinnant's
civil-to-days algo, 30 lines, handles negative years correctly.
Display ALSO benefits — UI was showing the raw ISO string
('2026-05-19T13:00:31+00:00') in the channel row. Now reads
'7 days ago' like every other YT client.
Each store + Coil image cache shows its actual on-disk byte count
next to its cap chip-row. Closes the loop on vc=59's cache controls
— users can see what each cap is doing instead of guessing.
- StorageUsage.sharedPrefBytes — reads dataDir/shared_prefs/X.xml
length directly. Cheap, advisory; not authoritative on
Android's internal SP layout but close enough to be useful.
- StorageUsage.coilDiskCacheBytes — pulls
SingletonImageLoader.get().diskCache?.size, returns 0 if Coil
hasn't lazily initialized yet.
- StorageUsage.format — KB/MB/GB renderer with 0 -> '—'.
Usage snapshot is captured once per Settings entry via remember{}
so File.length() doesn't refire on every recomposition.
Bundling the background-refresh worker (originally planned as
vc=60) into the same release as cache controls — they're both
storage-and-refresh user-facing knobs, ships cleaner together.
- StrawApp.onCreate calls FeedRefreshScheduler.applyFromSettings
- R8 keep rule for FeedRefreshWorker (same reason as
UpdateCheckWorker — WorkManager instantiates via reflection)
- Settings UI: 'Auto-refresh subs' toggle (default off) +
interval chip-row (30min / 1h / 6h) shown when enabled. Lives
in the existing Local cache section since it's the same
storage-and-refresh theme.
Worker calls uniffi.strawcore.subscriptionFeed which fans out 50
parallel RSS fetches in Rust — 50 subs refreshes in ~1-2s in the
background. Writes per-channel into FeedCacheStore so next cold
open of Subs paints instantly.
Previous replace_all on 'MAX_WATCHES' over-matched 'MAX_WATCHES_HARD'
and produced 'maxWatches()_HARD' which Kotlin parses as garbage.
Same for MAX_SEARCHES_HARD, MAX_RESUMES_HARD, MAX_QUERIES_HARD.
Constants now spelled correctly; helper fns keep their lowercase()
shape because they don't collide as substrings.
User-facing cache controls Cobb specifically asked for. Each
SharedPreferences-backed store now reads its cap from Settings
instead of a hardcoded constant:
- History watches: 50 / 200 / 1000 / 10k / Unlimited (was fixed 50)
- History searches: 50 / 200 / 1000 / 10k / Unlimited (was fixed 20)
- Resume positions: same options (was fixed 500)
- Search results cache: same options (was fixed 30 queries)
Each store also enforces a hard ceiling (100k for History +
Resume, 5k for SearchCache) so Unlimited doesn't OOM SP on a
hostile import.
New global Cache TTL: 1 day / 7 days / 30 days / 1 year /
Forever. Drops subs feed + search cache entries older than the
cutoff on every read. Defaults to 30 days.
Settings UI — new 'Cache & history limits' section inside the
existing Local cache block with one chip-row per cap + the TTL
chip-row + a 'Clear all caches' button that nukes FeedCache,
SearchCache, ResumePositions, History.watches, History.searches
on one tap.
Sequential withContext blocks left the slower of the two requests
fully serialized behind the faster one — 200-500ms wasted per video
open. async{}+await in a coroutineScope runs both on Dispatchers.IO
concurrently. Saves the slower-task latency on every detail-screen
load.
Rust port was overscoped — the dominant cost is network latency,
not parse, so a UniFFI hop wouldn't help and the parallelization
fix is a 5-line Kotlin change. Updated the Rust port plan memo
accordingly.
Cobb-reported 2026-05-26: tapping a related/search/subs video while
another is playing rendered the NEW detail page (title, description)
with the OLD video's last frame visible in the inline player slot.
Root cause — there's a window between streamInfo resolving for the
new URL and setPlayingFrom landing on the controller. PlayerView
bound to the controller renders the previous video's surface during
that window because the controller's MediaItem hasn't swapped yet.
Fix — observe NowPlaying.current and add a branch in InlinePlayer's
state-when that renders thumbnail + spinner when the controller is
still on a different streamUrl. Branch sits above the PlayerView
else-arm so the stale surface never gets attached. Flips to
PlayerView the moment NowPlaying.claim() lands the new URL.
LazyColumn's content lambda is LazyListScope, NOT @Composable, so
collectAsState() + remember() can't live inside the block body. Lift
both above the LazyColumn call (still inside the when{}'s else
branch). ChannelScreen — SubsPane and SearchScreen already had it
in the right scope.
quick-xml's BytesStart::name() returns a borrowed QName; calling
.as_ref() on it produced a &[u8] that outlived the QName by one
expression — borrowck E0716. Hoist the QName to a local so it
lives the full match arm.
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.