Commit graph

12258 commits

Author SHA1 Message Date
dd2345b1c8 Watched-status: persistent red bar + our play count (vc=74 items 2,3)
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.
2026-06-20 09:53:51 -07:00
6f95b6fa3d Persist hide-watched toggle across restart (vc=74 item 4)
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.
2026-06-20 07:57:51 -07:00
af3c39a662 Strip NewPipe: remove legacy :app + unused :desktopApp/:shared modules
Some checks failed
build-apk / build-and-publish (push) Failing after 44s
gitleaks / scan (push) Successful in 42s
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.
2026-06-20 07:19:33 -07:00
b58804e101 VideoDetail vc=73: smooth swipe-dismiss + collapsible Details + clean action bar
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).
2026-06-20 07:07:43 -07:00
5e89056f62 ci: Forgejo build workflow — per-repo straw-build image, gated auto-publish
Some checks failed
build-apk / build-and-publish (push) Failing after 1m5s
gitleaks / scan (push) Successful in 1m0s
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.
2026-06-19 20:18:32 -07:00
845a8b9cc7 release: explicit signing config + vc 72 (0.1.0-CF)
All checks were successful
gitleaks / scan (push) Successful in 53s
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.
2026-06-19 19:38:48 -07:00
4dfb2e1450 straw: infinite-scroll pagination for channel + search
All checks were successful
gitleaks / scan (push) Successful in 41s
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.
2026-06-19 18:21:42 -07:00
6f2ae831cc ci: broaden gitleaks allowlist — catch all variable-name patterns. Refs #300
All checks were successful
gitleaks / scan (push) Successful in 35s
2026-05-28 12:19:24 -07:00
05521b487d ci: gitleaks allowlist — InnerTube public API key + SharedPreferences key constants. Refs #300
Some checks failed
gitleaks / scan (push) Failing after 34s
2026-05-28 12:16:20 -07:00
beb994b6e2 ci: add gitleaks workflow (Sulkta canonical)
Some checks failed
gitleaks / scan (push) Failing after 33s
2026-05-27 22:14:58 -07:00
42cb945654 Public-flip audit: scrub audit-ticket prefixes + LAN refs + tighten README
URLs → git.sulkta.com. Audit-ticket prefixes (SPEC §N, audit Track X, vc=N
audit-fix, FIX (audit ...), PORT DEVIATION) stripped from comments — technical
reasoning retained. Crafting-table LAN refs softened to 'Sulkta build host'.
README sheds marketing scaffolding + stale status tables.
2026-05-27 13:29:53 -07:00
5a757bea23 vc=71: embed strawcore-core cleanup + DoS fixes
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.
2026-05-26 22:04:50 -07:00
d73a4b53aa vc=70: audit-fix sprint round 3 (wrapper)
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.
2026-05-26 21:55:29 -07:00
23fb6f52b0 vc=69: audit-fix sprint round 2 (regressions on round 1)
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.
2026-05-26 21:31:07 -07:00
5f2ba264b0 vc=68 fixup: enable reqwest 'stream' feature for bytes_stream 2026-05-26 20:56:24 -07:00
c960a1f424 vc=68: audit-fix sprint round 1 (11 HIGH + MED batch)
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
2026-05-26 20:53:25 -07:00
796244e065 vc=67: fix subs feed scroll jank
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.
2026-05-26 15:32:47 -07:00
dd151e322d vc=66: hybrid feed backfill — RSS-fast + streamInfo-complete
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.
2026-05-26 13:40:26 -07:00
7156208c3c vc=65: metadata consistency across ChannelScreen + SearchScreen rows
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.
2026-05-26 13:09:23 -07:00
944fbd4335 vc=64: UX polish — chip wrap + RelatedRow metadata
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.
2026-05-26 13:06:57 -07:00
7bd2740055 vc=63: fix stale-state nav-bug (new page shows, old video plays)
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.
2026-05-26 12:47:31 -07:00
6775f8252f vc=62: audit-fix sprint on playback regressions
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.
2026-05-26 12:31:27 -07:00
6cc789a8a0 vc=61: fix subs feed sort + date display
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.
2026-05-26 12:24:33 -07:00
26c9483b94 vc=60: storage usage readouts in cache settings
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.
2026-05-26 11:59:19 -07:00
aead95f1bc vc=59 cont: wire bg subs refresh + R8 keep + Settings UI
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.
2026-05-26 11:38:04 -07:00
c4bf7446c9 vc=59 fixup: restore MAX_*_HARD const declarations
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.
2026-05-26 11:36:39 -07:00
2e75938f4e vc=59: per-store cache caps + TTL + Clear all caches
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.
2026-05-26 11:33:53 -07:00
7fff36c5e3 vc=58: parallel SponsorBlock + RYD fetch on video open
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.
2026-05-26 11:27:34 -07:00
8dec2f2621 vc=57: hide stale inline player frame during video switch
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.
2026-05-26 11:16:00 -07:00
50f4ce0a6c vc=56 fixup: hoist hideShorts collectAsState out of LazyListScope
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.
2026-05-26 10:50:37 -07:00
12acf41c08 vc=56 fixup: bind QName temporary before passing to local_name
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.
2026-05-26 10:47:45 -07:00
3a57696b46 vc=56 fixup: actually add reqwest+quick-xml+futures to Cargo.toml
Earlier Edit's old_string didn't match the file shape so the dep
additions never landed. Re-adding properly after android_logger.
2026-05-26 10:46:26 -07:00
341261584a vc=56: subs feed via RSS (5-10x faster) + hide-shorts filter
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.
2026-05-26 10:44:06 -07:00
ccd24c4ed3 vc=55: in-app auto-updater polling fdroid.sulkta.com
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.
2026-05-26 09:40:07 -07:00
fbccdce65a vc=54: red progress-bar overlay on video thumbnails
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.
2026-05-26 09:28:04 -07:00
080346716b fixup vc=53: keep screen on while fullscreen + inline player attached
Cobb hit screen-timeout-while-watching mid-build. View-level
keepScreenOn=true on both PlayerViews — propagates to the
window while the view is attached, releases the wake-lock
automatically when the user backs out. Same pattern Media3
recommends for video apps.
2026-05-26 09:09:07 -07:00
e26a3eca19 vc=53: scrub-point store + auto-resume on video open
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).
2026-05-26 09:04:50 -07:00
ebe1fc8464 vc=52: R8 enabled + surface-handoff polish
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.
2026-05-26 08:43:06 -07:00
dc1fff00db vc=51: bottom-clear sweep + DASH res cap + headphone pause
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).
2026-05-26 08:14:16 -07:00
208cdf6326 vc=50: Settings respects nav-bar inset + minibar overlay
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.
2026-05-26 07:36:33 -07:00
714a2f8a92 fixup vc=49: missing Settings import in VideoDetailScreen 2026-05-26 07:21:09 -07:00
0f946d8b4e vc=49: Auto-start playback setting (cold-open autoplay)
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.
2026-05-26 07:17:29 -07:00
62cc18c940 fixup vc=48: pickAutoplayCandidate uses try/catch (runCatching not suspend-aware) 2026-05-26 07:08:39 -07:00
964bcddb3a vc=48: autoplay (off / same-channel / yt-related) + SB for queued items
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.
2026-05-26 07:04:35 -07:00
02381edf03 vc=47: queue — Play next + Add to queue from long-press menu
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.
2026-05-26 05:39:19 -07:00
406fd8924a fixup vc=46: missing remember/getValue imports in SearchScreen 2026-05-26 04:33:16 -07:00
c3583457fb vc=46: long-press video actions — save to playlist + share
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.
2026-05-26 04:28:14 -07:00
c515fabf71 vc=45: tappable channel name in search + channel row on VideoDetail
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).
2026-05-25 22:11:12 -07:00
5d9cf3e370 vc=44: fix back-from-fullscreen showing thumbnail placeholder
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.
2026-05-25 16:33:50 -07:00
2cfb26bbd3 vc=43: loop round 5/5 — final ship
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
2026-05-25 15:44:18 -07:00