Compare commits

...
Sign in to create a new pull request.

71 commits

Author SHA1 Message Date
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
10154c380b vc=42: loop round 4/5 — surgical cleanup, round 7 hit diminishing returns
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.
2026-05-25 15:36:00 -07:00
ecc54aaf38 vc=41: loop round 3/5 — round-2 misses caught + duplicate-entry zip guard
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)
2026-05-25 15:25:25 -07:00
da48109a4d vc=40: loop round 2/5 — round-1 misses + new HIGHs from round-5 audits
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).
2026-05-25 15:12:30 -07:00
b8325d1726 vc=39: loop round 1/5 — 9 HIGH + 7 MED from 3 Opus round-4 audits
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.
2026-05-25 14:56:38 -07:00
cbdba302ce vc=38: round-3 audit-fix sprint — 9 HIGH + 7 MED
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).
2026-05-25 14:29:32 -07:00
780bb6152c vc=37 (rust): scrub PII from strawcore info-logs
CVE round-2 HIGH-2: android_logger is configured at info-level in
release builds, so log::info!('strawcore::search query={}', query)
emits the user's actual search query to logcat. LogDump.scrubLine's
regex only catches googlevideo URLs + signed params — bare search
text rides through into a Settings → Export Logs share-sheet
attachment intact. Same for channel_info / stream_info URLs.

Replaced the value-bearing logs with shape-only (query_len /
input_len). The shape is enough to debug 'why did the search
return empty?' without the privacy hit.
2026-05-25 14:11:00 -07:00
ec9d2f37af vc=37 fix: pool changed from StateFlow to plain var; drop .value refs 2026-05-25 14:09:11 -07:00
567423336c vc=37: round-2 audit-fix sprint — 2 CRIT + 11 HIGH + 4 MED
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.
2026-05-25 14:05:58 -07:00
d1ee9379e0 vc=36: audit-fix tail — atomic setPlayingFrom, cache wipe, polish
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.
2026-05-25 13:43:45 -07:00
8f7ec129b3 vc=35 fix: missing fillMaxWidth import in PlayerScreen 2026-05-25 13:31:02 -07:00
e76a325faa vc=35: audit-fix sprint — 5 CRIT + 14 HIGH + opportunistic MEDs
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
2026-05-25 13:27:30 -07:00
a776fbf2e4 vc=34: settings batch — theme, cache toggle, log dump, reactive search
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.
2026-05-25 13:01:41 -07:00
c74b06436f vc=33 fix: add FeedCacheStore.kt to git
git commit -am only stages tracked files. The new file existed locally
but wasn't in the previous commit; build failed with Unresolved
reference 'FeedCache' on every site that used it.
2026-05-25 12:54:09 -07:00
69560889ae vc=33: persistent feed cache + strawcore avatar fix (via strawcore-core)
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.
2026-05-25 12:50:13 -07:00
2afdcf3d5c vc=32 fix: drop SearchItem.uploader_avatar — not on StreamInfoItem
I confused StreamInfo (the big single-video struct, has
uploader_avatars: ImageSet) with StreamInfoItem (the card struct used
in search results / channel video lists / related streams — no
uploader_avatars field). cargoBuildHost caught it: E0609 no field
`uploader_avatars`.

Drop the field from SearchItem (and from the Kotlin construction
sites). For the subs feed and "more from this channel" we already
use the channel-level avatar from ChannelInfo.avatar, which is the
right granularity anyway (every video from one channel shares one
avatar). Per-card uploader avatars on search/related stay null until
strawcore-core extracts them on StreamInfoItem too.
2026-05-25 12:38:50 -07:00
544035b30c vc=32: subs feed — dates, watched filter, infinite scroll, avatar fallback
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.
2026-05-25 12:34:02 -07:00
9aafc003cb vc=31: smoother swipe-to-minimize
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.
2026-05-25 12:19:12 -07:00
20ee8023c1 vc=30: minibar above nav buttons + simpler icon
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.
2026-05-25 11:58:43 -07:00
29ffed265b vc=29: fullscreen overlay controls respect display cutout + status bar
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.
2026-05-25 11:54:45 -07:00
2e339814fd vc=28: edge-to-edge player, nav-bar inset, video-track reset, app icon
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.
2026-05-25 11:43:38 -07:00
35f5affec3 vc=27: swipe-down on detail page + Background/Popout buttons
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.
2026-05-25 11:17:20 -07:00
885398e3bd vc=26: look + feel pass — sulkta.com palette + Material Icons
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.
2026-05-25 17:46:23 +00:00
21fc81ee77 vc=25: audit-fix sprint — CRIT + HIGH + MED + LOW cleanup
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).
2026-05-25 17:01:10 +00:00
1443bb8ef7 vc=24: NewPipe/Tubular settings import
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.
2026-05-25 16:44:27 +00:00
3ff9740c40 detail: wrap action row with FlowRow so Save doesnt clip on narrow widths 2026-05-25 16:25:30 +00:00
1be4c4265f vc=23: minibar + MediaController unification + Downloads UI + green theme
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.
2026-05-25 16:23:05 +00:00
e7d45aa6b4 vc=22: inline→fullscreen position handoff + local playlists 2026-05-25 15:57:56 +00:00
599d299b2a vc=21: seamless background-audio handoff on 🎧 + HOME
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).
2026-05-25 03:55:39 +00:00
709af57f42 v0.1.0-AF (vc=20): channel-videos fix for subscription feed
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.
2026-05-24 20:07:04 -07:00
f70b8b71b9 v0.1.0-AE (vc=19): rust pipeline cutover
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.
2026-05-24 18:45:35 -07:00
e80fa4252c Clean cutover — Kotlin off NewPipeExtractor, onto uniffi.strawcore
Brings the Phase U Android-side integration onto a feature branch
where the rust wrapper now bridges to strawcore-core (the new NPE
port) instead of rustypipe.

  * strawApp/build.gradle.kts — JNA dep + cargoBuild + cargoBuildHost
    + uniffiBindgen + the merge/compile task wiring. libs.newpipe
    .extractor dropped.
  * StrawApp.kt — uniffi.strawcore.initLogging(); NewPipe.init() gone
  * 5 ViewModels (Search/VideoDetail/Player/Channel/Subscriptions)
    swapped to call uniffi.strawcore.{search,streamInfo,channelInfo}
  * PlayerScreen + PlaybackService adjusted for the new StreamInfo
    shape (dash_mpd_url / hls_url instead of NPE's manifests)
  * IosSafeHttpDataSource carried forward — strawcore-core gives us
    Android-primary URLs so the iOS cap path is mostly dead code,
    but kept as belt-and-suspenders for the rare HLS-fallback
  * NewPipeDownloader.kt + util/Thumbnails.kt deleted

Files taken wholesale from sulkta branch — the wrapper's UniFFI
surface is identical between sulkta (rustypipe-backed) and the
current Phase-7-bridged-to-strawcore-core code, so Kotlin doesn't
care which extractor is under the hood.
2026-05-24 17:54:41 -07:00
85 changed files with 9440 additions and 1484 deletions

186
README.md
View file

@ -1,153 +1,77 @@
<h3 align="center">We are <i>rewriting</i> large chunks of the codebase, to bring about <a href="https://newpipe.net/blog/pinned/announcement/newpipe-0.27.6-rewrite-team-states/#the-refactor">a modern and stable NewPipe</a>! You can download nightly builds <a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases">here</a>.</h3>
<h4 align="center">Please work on the <code>refactor</code> branch if you want to contribute <i>new features</i>. The current codebase is in maintenance mode and will only receive <i>bugfixes</i>.</h4>
# Straw
<p align="center"><a href="https://newpipe.net"><img src="assets/new_pipe_icon_5.png" width="150"></a></p>
<h2 align="center"><b>NewPipe</b></h2>
<h4 align="center">A libre lightweight streaming front-end for Android.</h4>
A Sulkta fork of [NewPipe](https://github.com/TeamNewPipe/NewPipe). Android YouTube
client, Compose UI, Media3 player, with [SponsorBlock](https://sponsor.ajay.app/)
and [Return YouTube Dislike](https://returnyoutubedislike.com/) baked in.
<p align="center"><a href="https://f-droid.org/packages/org.schabi.newpipe/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on-en.svg" alt="Get it on F-Droid" width=206/></a></p>
The extractor is `strawcore`, a Rust port of NewPipeExtractor exposed to Kotlin
via UniFFI. No InnerTube/JS deobf code path lives on the JVM anymore.
<p align="center">
<a href="https://github.com/TeamNewPipe/NewPipe/releases" alt="GitHub NewPipe releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe.svg" ></a>
<a href="https://github.com/TeamNewPipe/NewPipe-nightly/releases" alt="GitHub NewPipe nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-nightly.svg?labelColor=purple&label=dev%20nightly"></a>
<a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases" alt="GitHub NewPipe refactor nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-refactor-nightly.svg?labelColor=purple&label=refactor%20nightly"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0" alt="License: GPLv3"><img src="https://img.shields.io/badge/License-GPL%20v3-blue.svg"></a>
<a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/actions/workflows/ci.yml/badge.svg?branch=dev&event=push"></a>
<a href="https://hosted.weblate.org/engage/newpipe/" alt="Translation Status"><img src="https://hosted.weblate.org/widgets/newpipe/-/svg-badge.svg"></a>
</p>
## Install
<p align="center">
<a href="https://web.libera.chat/#newpipe" alt="IRC channel: #newpipe"><img src="https://img.shields.io/badge/IRC%20chat-%23newpipe-brightgreen.svg"></a>
<a href="https://matrix.to/#/#newpipe:matrix.newpipe-ev.de" alt="Matrix channel: #newpipe"><img src="https://img.shields.io/badge/Matrix%20chat-%23newpipe-blue"></a>
</p>
F-Droid repo: <https://fdroid.sulkta.com/fdroid/repo>
<hr>
<p align="center"><a href="#screenshots">Screenshots</a> &bull; <a href="#supported-services">Supported Services</a> &bull; <a href="#description">Description</a> &bull; <a href="#features">Features</a> &bull; <a href="#installation-and-updates">Installation and updates</a> &bull; <a href="#contribution">Contribution</a> &bull; <a href="#donate">Donate</a> &bull; <a href="#license">License</a></p>
<p align="center"><a href="https://newpipe.net">Website</a> &bull; <a href="https://newpipe.net/blog/">Blog</a> &bull; <a href="https://newpipe.net/FAQ/">FAQ</a> &bull; <a href="https://newpipe.net/press/">Press</a></p>
<hr>
Add the repo in your F-Droid client of choice, then install Straw.
*Read this document in other languages: [Deutsch](doc/README.de.md), [English](README.md), [Español](doc/README.es.md), [Français](doc/README.fr.md), [हिन्दी](doc/README.hi.md), [Italiano](doc/README.it.md), [한국어](doc/README.ko.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [ਪੰਜਾਬੀ ](doc/README.pa.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Soomaali](doc/README.so.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md), [অসমীয়া](doc/README.asm.md), [Српски](doc/README.sr.md), [العربية](README.ar.md)*
The app also self-updates from the same repo when an APK lands there with a
higher `versionCode`.
> [!warning]
> <b>THIS APP IS IN BETA, SO YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE IN OUR GITHUB REPOSITORY BY FILLING OUT THE ISSUE TEMPLATE.</b>
>
> <b>PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS.</b>
## What's in
## Screenshots
- Search, video detail, channel pages, playlists
- Inline player + fullscreen + minibar + background audio + PiP
- Media3 ExoPlayer (DASH / HLS / progressive / merged DASH chunks)
- SponsorBlock auto-skip (categories user-toggleable)
- Return YouTube Dislike on video detail
- RSS-based subscription feed (fast — ~1s for 50 subs)
- Hide-shorts / hide-paid / hide-age-restricted feed filters
- Resume positions + watch history + search history
- Local playlists, downloads (video + audio)
- NewPipe-format settings import (subs + playlists + history)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/00.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/00.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/01.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/02.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/03.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/04.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/05.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/06.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/07.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/08.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/08.png)
<br/><br/>
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png" width=405>](fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png)
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png" width=405>](fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png)
## What's out (on purpose)
### Supported Services
- Trending / algorithmic feeds. Subscriptions only.
- iOS / desktop targets. Android-only for now.
- Google Play Services anything.
NewPipe currently supports these services:
## Layout
<!-- We link to the service websites separately to avoid people accidentally opening a website they didn't want to. -->
* YouTube ([website](https://www.youtube.com/)) and YouTube Music ([website](https://music.youtube.com/)) ([wiki](https://en.wikipedia.org/wiki/YouTube))
* PeerTube ([website](https://joinpeertube.org/)) and all its instances (open the website to know what that means!) ([wiki](https://en.wikipedia.org/wiki/PeerTube))
* Bandcamp ([website](https://bandcamp.com/)) ([wiki](https://en.wikipedia.org/wiki/Bandcamp))
* SoundCloud ([website](https://soundcloud.com/)) ([wiki](https://en.wikipedia.org/wiki/SoundCloud))
* media.ccc.de ([website](https://media.ccc.de/)) ([wiki](https://en.wikipedia.org/wiki/Chaos_Computer_Club))
As you can see, NewPipe supports multiple video and audio services. Though it started off with YouTube, other people have added more services over the years, making NewPipe more and more versatile!
Partially due to circumstance, and partially due to its popularity, YouTube is the best supported out of these services. If you use or are familiar with any of these other services, please help us improve support for them! We're looking for maintainers for SoundCloud and PeerTube.
If you intend to add a new service, please get in touch with us first! Our [docs](https://teamnewpipe.github.io/documentation/) provide more information on how a new service can be added to the app and to the [NewPipe Extractor](https://github.com/TeamNewPipe/NewPipeExtractor).
## Description
NewPipe works by fetching the required data from the official API (e.g. PeerTube) of the service you're using. If the official API is restricted (e.g. YouTube) for our purposes, or is proprietary, the app parses the website or uses an internal API instead. This means that you don't need an account on any service to use NewPipe.
Also, since they are free and open source software, neither the app nor the Extractor use any proprietary libraries or frameworks, such as Google Play Services. This means you can use NewPipe on devices or custom ROMs that do not have Google apps installed.
### Features
* Watch videos at resolutions up to 4K
* Listen to audio in the background, only loading the audio stream to save data
* Popup mode (floating player, aka Picture-in-Picture)
* Watch live streams
* Show/hide subtitles/closed captions
* Search videos and audios (on YouTube, you can specify the content language as well)
* Enqueue videos (and optionally save them as local playlists)
* Show/hide general information about videos (such as description and tags)
* Show/hide next/related videos
* Show/hide comments
* Search videos, audios, channels, playlists and albums
* Browse videos and audios within a channel
* Subscribe to channels (yes, without logging into any account!)
* Get notifications about new videos from channels you're subscribed to
* Create and edit channel groups (for easier browsing and management)
* Browse video feeds generated from your channel groups
* View and search your watch history
* Search and watch playlists (these are remote playlists, which means they're fetched from the service you're browsing)
* Create and edit local playlists (these are created and saved within the app, and have nothing to do with any service)
* Download videos/audios/subtitles (closed captions)
* Open in Kodi
* Watch/Block age-restricted material
<!-- Hidden span to keep old links compatible. You should remove this span if you're translating the README into another language.-->
<span id="updates"></span>
## Installation and updates
You can install NewPipe using one of the following methods:
1. Add our custom repo to F-Droid and install it from there. The instructions are here: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/
2. Download the APK from [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases), [compare the signing key](#apk-info) and install it.
3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, and then push the update to users.
4. Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods.
5. If you're interested in a specific feature or bugfix provided in a Pull Request in this repo, you can also download its APK from within the PR. Read the PR description for instructions. The great thing about PR-specific APKs is that they're installed side-by-side the official app, so you don't have to worry about losing your data or messing anything up.
We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other (meaning that if you installed NewPipe using either method 1 or 2, you can also update NewPipe using the other), but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app. When using method 5, each APK is signed with a different random key supplied by GitHub Actions, so you cannot even update it. You will have to backup and restore the app data each time you wish to use a new APK.
In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's core functionality breaks and F-Droid doesn't have the latest update yet), we recommend following this procedure:
1. Back up your data via Settings > Backup and Restore > Export Database so you keep your history, subscriptions, and playlists
2. Uninstall NewPipe
3. Download the APK from the new source and install it
4. Import the data from step 1 via Settings > Backup and Restore > Import Database
> [!Note]
> When you're importing a database into the official app, always make sure that it is the one you exported _from_ the official app. If you import a database exported from an APK other than the official app, it may break things. Such an action is unsupported, and you should only do so when you're absolutely certain you know what you're doing.
### APK Info
This is the SHA fingerprint of NewPipe's signing key to verify downloaded APKs which are signed by us. The fingerprint is also available on [NewPipe's website](https://newpipe.net#download). This is relevant for method 2.
```
CB:84:06:9B:D6:81:16:BA:FA:E5:EE:4E:E5:B0:8A:56:7A:A6:D8:98:40:4E:7C:B1:2F:9E:75:6D:F5:CF:5C:AB
strawApp/ Sulkta-authored app — Compose UI, Media3 wiring, SB + RYD clients
rust/ strawcore — UniFFI wrapper around the Rust extractor
shared/ KMP scaffold inherited from upstream NewPipe (unused for now)
app/ Upstream NewPipe :app module — kept for reference
```
## Contribution
Whether you have ideas, translations, design changes, code cleaning, or even major code changes, help is always welcome. The app gets better and better with each contribution, no matter how big or small! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md).
## Build
<a href="https://hosted.weblate.org/engage/newpipe/">
<img src="https://hosted.weblate.org/widgets/newpipe/-/287x66-grey.png" alt="Translation status" />
</a>
```
./gradlew :strawApp:assembleDebug
```
## Donate
If you like NewPipe, you're welcome to send a donation. We prefer Liberapay, as it is both open-source and non-profit. For further info on donating to NewPipe, please visit our [website](https://newpipe.net/donate).
Requires the Rust toolchain plus the four Android targets:
<table>
<tr>
<td><a href="https://liberapay.com/TeamNewPipe/"><img src="https://upload.wikimedia.org/wikipedia/commons/2/27/Liberapay_logo_v2_white-on-yellow.svg" alt="Liberapay" width="80px" ></a></td>
<td><a href="https://liberapay.com/TeamNewPipe/"><img src="assets/liberapay_qr_code.png" alt="Visit NewPipe at liberapay.com" width="100px"></a></td>
<td><a href="https://liberapay.com/TeamNewPipe/donate"><img src="assets/liberapay_donate_button.svg" alt="Donate via Liberapay" height="35px"></a></td>
</tr>
</table>
```
rustup target add aarch64-linux-android armv7-linux-androideabi \
x86_64-linux-android i686-linux-android
cargo install cargo-ndk uniffi-bindgen
```
## Privacy Policy
The NewPipe project aims to provide a private, anonymous experience for using web-based media services. Therefore, the app does not collect any data without your consent. NewPipe's privacy policy explains in detail what data is sent and stored when you send a crash report, or leave a comment in our blog. You can find the document [here](https://newpipe.net/legal/privacy/).
…and `ANDROID_NDK_HOME` pointing at NDK r27c (or newer). The Gradle build runs
`cargo ndk` + `uniffi-bindgen` automatically.
## License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html)
NewPipe is Free Software: You can use, study, share, and improve it at will. Specifically you can redistribute and/or modify it under the terms of the [GNU General Public License](https://www.gnu.org/licenses/gpl.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
GPL-3.0-or-later, inherited from upstream NewPipe.
## Upstream
This repo tracks <https://github.com/TeamNewPipe/NewPipe>. Upstream changes
get pulled periodically via the `upstream` remote.
## Disclaimer
Not affiliated with YouTube, Google, NewPipe e.V., the SponsorBlock project,
or Return YouTube Dislike. Trademarks belong to their owners. Straw uses
public web endpoints; nothing here authenticates to any account.

View file

@ -15,6 +15,46 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw
const val STRAW_VERSION_CODE = 18
const val STRAW_VERSION_NAME = "0.1.0-AD"
//
// vc=23 / 0.1.0-AI — minibar + downloads UI + green theme:
// * MediaController/MediaSessionService unification — single ExoPlayer
// owned by PlaybackService, every UI surface is a controller client.
// Inline player on VideoDetail, fullscreen Player, and the new
// minibar overlay all drive the same underlying player; nothing
// restarts on screen transitions.
// * Persistent minibar overlay at the bottom of every non-Player
// screen whenever something is loaded. Tap → expand to fullscreen.
// Drag-down on fullscreen → minimize to minibar. ⌄ overlay button
// also minimizes. × on the minibar stops + clears.
// * Downloads page wired into the drawer.
// * Theme: forest-green primary palette in place of M3 default
// lavender / NewPipe red — modern, clean, distinct.
//
// vc=22 / 0.1.0-AH — V-2 player polish + local playlists:
// * Inline → fullscreen now hands off seek position. Tap Play (or the
// ⛶ pill on the inline player) while the inline is mid-track and
// the fullscreen Player picks up at the same point. Same handoff
// pattern as fullscreen → background from vc=21.
// * Local playlists: drawer entry "Playlists", "Save" button on
// VideoDetail. SharedPreferences-backed, no queue/autoplay yet
// (tap an entry to open VideoDetail as normal).
//
// vc=21 / 0.1.0-AG — player hand-off polish:
// * 🎧 background-audio button now captures the current position and
// resumes the foreground service from there instead of restarting.
// * HOME / recents button while on the player now hands off seamlessly
// to background audio (same position-preserving path) instead of
// auto-entering Picture-in-Picture. Manual PiP via the ⊟ overlay
// button is unchanged.
//
// vc=20 / 0.1.0-AF — channel-videos fix on top of the rust pipeline
// cutover. vc=19 returned empty subscription feeds because
// strawcore-core's channel_info wasn't doing the second browse for the
// Videos tab AND wasn't parsing the new lockupViewModel shape.
//
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 71
const val STRAW_VERSION_NAME = "0.1.0-CE"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -14,7 +14,7 @@ members = ["strawcore"]
edition = "2021"
license = "GPL-3.0-or-later"
authors = ["Sulkta-Coop"]
repository = "http://192.168.0.5:3001/Sulkta-Coop/straw"
repository = "https://git.sulkta.com/Sulkta-Coop/straw"
[profile.release]
# Strip debug info, run thin LTO. APK size matters more than build time here.
@ -29,6 +29,6 @@ opt-level = "z"
url = "2"
[profile.dev]
# Keep debug builds fast — we're rebuilding constantly during U-1..U-5.
# Keep debug builds fast — we rebuild often during NDK cross-compile.
opt-level = 0
debug = 1

View file

@ -20,12 +20,12 @@ moves to Rust.
## Build chain
```
crafting-table
Build container (Sulkta uses one; any toolchain matching this layout works)
├── rustup stable (target add: aarch64-linux-android, armv7-linux-androideabi,
│ x86_64-linux-android, i686-linux-android)
├── cargo-ndk (cross-compile helper)
├── android-sdk (ANDROID_HOME, sdkmanager, build-tools, platforms)
└── android-ndk (ANDROID_NDK_HOME, r27c LTS at /caches/android-sdk/ndk/...)
└── android-ndk (ANDROID_NDK_HOME, r27c LTS)
Gradle (strawApp/build.gradle.kts)
├── cargoBuild Exec task → cargo ndk -t <abi>... -o jniLibs/ build --release

View file

@ -30,15 +30,20 @@ strawcore-core = { path = "../../../strawcore" }
# Android target has no pre-generated bindings — flip on the `bindgen`
# feature so cargo regenerates at build time. Direct dep so the feature
# flag propagates (cargo's unified feature resolver lifts this to the
# transitive use). Crafting-table has libclang preinstalled.
# transitive use). Build host needs libclang installed.
rquickjs-sys = { version = "0.11", default-features = false, features = ["bindgen"] }
# Error glue.
thiserror = "1"
# Single-threaded init for the runtime + extractor singletons.
once_cell = "1"
# Android log integration — `log::info!()` ends up in `adb logcat -s strawcore`.
log = "0.4"
android_logger = { version = "0.14", default-features = false }
# subscription RSS feed fan-out. reqwest dedupes against
# strawcore-core's already-pulled reqwest; quick-xml is small (~200KB);
# futures for buffer_unordered. rustls-tls avoids the NDK openssl headers
# headache.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "stream"] }
quick-xml = "0.36"
futures = "0.3"
[build-dependencies]
uniffi = { version = "0.28", features = ["build"] }

View file

@ -23,7 +23,8 @@ pub struct ChannelInfo {
#[uniffi::export(async_runtime = "tokio")]
pub async fn channel_info(input: String) -> Result<ChannelInfo, StrawcoreError> {
log::info!("strawcore::channel_info input={}", input);
log::info!("strawcore::channel_info input_len={}", input.len());
crate::runtime::ensure_initialized();
let identifier = resolve_channel_identifier(&input)?;
let core = tokio::task::spawn_blocking(move || core_channel_info(identifier))
.await

View file

@ -31,13 +31,47 @@ pub enum StrawcoreError {
RequiresLogin { detail: String },
}
/// Drop the `continue=<signed-url>` param from a google.com/sorry/...
/// URL while leaving every other param intact. Used only for surfacing
/// recaptcha challenge URLs to the UI; keeps the URL tappable for the
/// user to solve the challenge while scrubbing the embedded
/// googlevideo signature.
fn strip_continue_param(url: &str) -> String {
let (base, query) = match url.split_once('?') {
Some(pair) => pair,
None => return url.to_owned(),
};
let filtered: Vec<&str> = query
.split('&')
.filter(|kv| {
let key = kv.split_once('=').map(|(k, _)| k).unwrap_or(*kv);
!key.eq_ignore_ascii_case("continue")
})
.collect();
if filtered.is_empty() {
base.to_owned()
} else {
format!("{}?{}", base, filtered.join("&"))
}
}
impl From<strawcore_core::exceptions::ExtractionError> for StrawcoreError {
fn from(e: strawcore_core::exceptions::ExtractionError) -> Self {
use strawcore_core::exceptions::{ContentUnavailable, ExtractionError, NetworkError};
match e {
ExtractionError::Network(NetworkError::Recaptcha { url }) => {
// Strip the `continue=` query param before propagating.
// google.com/sorry/index carries the full signed
// googlevideo URL in `continue=` so the user can be
// sent back to the stream after solving — but
// surfacing that to the UI is a credential leak via
// screenshot, and Kotlin's LogDump scrubber only
// catches googlevideo.com hosts. The challenge URL
// itself still solves without `continue=`, so the
// user can tap to unblock without leaking the
// signature/expire/pot token.
StrawcoreError::RequiresLogin {
detail: format!("reCAPTCHA at {url}"),
detail: format!("reCAPTCHA challenge: {}", strip_continue_param(&url)),
}
}
ExtractionError::Network(NetworkError::Transport(msg)) => {

505
rust/strawcore/src/feed.rs Normal file
View file

@ -0,0 +1,505 @@
// fast subscription feed via YouTube's per-channel RSS endpoint.
//
// YouTube serves `https://www.youtube.com/feeds/videos.xml?channel_id=UCxxx`
// — small Atom XML, no auth, no JS, no InnerTube round-trip. Replaces the
// per-channel `channel_info()` page-scrape that was costing ~500ms each
// (the bottleneck behind NewPipe's "pull to refresh takes 30 seconds for
// 50 subs" UX). Fan-out 50× concurrent via `futures::stream::buffer_unordered`
// turns a 50-sub refresh from ~5-8s parallel-12 to ~1s parallel-50.
//
// RSS is intentionally lossy — it returns title/url/published/thumbnail
// only. No duration, no view count, no shorts/age/paid flags. That's the
// right trade for a feed-refresh use case: tap-through still goes through
// the full stream_info path to fetch the rich metadata when actually
// needed.
use std::sync::OnceLock;
use std::time::Duration;
use futures::stream::{self, StreamExt};
use reqwest::Client;
use crate::error::StrawcoreError;
use crate::search::SearchItem;
const RSS_BASE: &str = "https://www.youtube.com/feeds/videos.xml?channel_id=";
const MAX_CONCURRENT: usize = 50;
const PER_CHANNEL_TIMEOUT_S: u64 = 8;
/// Cap on the body bytes we'll read for a single RSS fetch. Real YT
/// Atom feeds are ~5-30 KB; 2 MiB leaves comfortable headroom while
/// blocking a hostile or compromised host from streaming GB-scale
/// bodies into JVM memory inside the 8s timeout.
const RSS_MAX_BYTES: usize = 2 * 1024 * 1024;
/// Cap on parsed entries per channel — RSS normally returns 15.
/// 50 leaves headroom for one-off legitimate variance; anything
/// past that is a sign the feed isn't what we expect.
const RSS_MAX_ENTRIES: usize = 50;
/// Year range we trust civil-to-days math for. Strawcore RSS only
/// emits real-world recent uploads; clamping here turns adversarial
/// year fields into a parse failure rather than i64 overflow.
const YEAR_MIN: i32 = 1970;
const YEAR_MAX: i32 = 2200;
/// Hybrid-backfill metadata: just the two fields RSS doesn't return
/// (view count + duration). Kotlin calls this lazily for visible feed
/// items after the RSS-fed paint to fill in the gaps that
/// channel_feed_rss leaves empty.
///
/// built specifically so the subs feed can show 'N views ·
/// X duration' the way YT does, without paying the full channel_info
/// page-scrape cost on initial paint. The underlying stream_info IS
/// heavier than we'd like (~500ms each, runs JS deobf for play URLs
/// we'll discard) — future opt would be to parse the watch-page HTML
/// JSON state directly for just these two fields. ~100ms savings per
/// call but ~150 lines of HTML/JSON pluck logic. Punted until needed.
#[derive(Debug, Clone, uniffi::Record)]
pub struct EnrichedFeedMetadata {
pub view_count: i64,
pub duration_seconds: i64,
}
#[uniffi::export(async_runtime = "tokio")]
pub async fn enrich_feed_item(
video_url: String,
) -> Result<EnrichedFeedMetadata, StrawcoreError> {
crate::runtime::ensure_initialized();
let info = crate::stream::stream_info(video_url).await?;
Ok(EnrichedFeedMetadata {
view_count: info.view_count,
duration_seconds: info.duration_seconds,
})
}
/// Shared reqwest Client — DNS resolver + TLS keepalive + connection
/// pool live here so a 50-channel fan-out reuses one pool instead of
/// paying 50 handshakes.
static RSS_CLIENT: OnceLock<Client> = OnceLock::new();
fn rss_client() -> Result<&'static Client, StrawcoreError> {
if let Some(c) = RSS_CLIENT.get() {
return Ok(c);
}
let client = Client::builder()
.timeout(Duration::from_secs(PER_CHANNEL_TIMEOUT_S))
.user_agent(concat!("Mozilla/5.0 (Android; Mobile; Straw/", env!("CARGO_PKG_VERSION"), ")"))
// Cap redirect chains so a misconfigured/hostile feed can't
// spin a server out of our 8s budget.
.redirect(reqwest::redirect::Policy::limited(3))
.build()
.map_err(|e| StrawcoreError::Extractor {
msg: format!("http client build: {e}"),
})?;
Ok(RSS_CLIENT.get_or_init(|| client))
}
/// Single-channel RSS — Kotlin keeps its per-channel cache + fan-out
/// (parallelism cranked to 50 in the wrapper). Each call is ~50-150ms
/// instead of the ~500ms channelInfo page-scrape, so a 50-sub refresh
/// drops from ~5-8s to ~1s.
#[uniffi::export(async_runtime = "tokio")]
pub async fn channel_feed_rss(
channel_url: String,
) -> Result<Vec<SearchItem>, StrawcoreError> {
crate::runtime::ensure_initialized();
log::info!("strawcore::channel_feed_rss url_len={}", channel_url.len());
let client = rss_client()?;
Ok(fetch_channel_rss(client, &channel_url).await.unwrap_or_default())
}
/// Bulk subscription feed fan-out — for callers that want one round-trip
/// to Rust. Currently unused by the Android app (it sticks with the
/// per-channel cache), but exposed for future desktop / web variants
/// or for a "warm everything" background prefetch.
#[uniffi::export(async_runtime = "tokio")]
pub async fn subscription_feed(
channel_urls: Vec<String>,
) -> Result<Vec<SearchItem>, StrawcoreError> {
crate::runtime::ensure_initialized();
log::info!("strawcore::subscription_feed channels={}", channel_urls.len());
if channel_urls.is_empty() {
return Ok(Vec::new());
}
let client = rss_client()?;
let results: Vec<Vec<SearchItem>> = stream::iter(channel_urls.into_iter())
.map(|url| async move { fetch_channel_rss(client, &url).await.unwrap_or_default() })
.buffer_unordered(MAX_CONCURRENT)
.collect()
.await;
// Per-channel ordering is RSS-served-newest-first. Cross-channel
// interleave is the caller's responsibility — Kotlin's mergeFromCache
// sorts by parsed recency, which is the source of truth. Returning
// the flat list as-is. (an earlier version sorted lexicographically
// on the relative-date STRING, which is wrong because "10 hours
// ago" < "2 hours ago" in cmp order)
Ok(results.into_iter().flatten().collect())
}
async fn fetch_channel_rss(client: &Client, channel_url: &str) -> Option<Vec<SearchItem>> {
let channel_id = extract_channel_id(channel_url)?;
let url = format!("{RSS_BASE}{channel_id}");
let resp = client
.get(&url)
.send()
.await
.ok()?
.error_for_status()
.ok()?;
// Streaming body read with a hard byte cap — `.text()` reads
// unbounded into a String.
let body = read_capped_body(resp).await?;
parse_rss(&body, channel_id)
}
/// Drain a reqwest Response into a String, bailing out (return None) if
/// the body exceeds RSS_MAX_BYTES.
async fn read_capped_body(resp: reqwest::Response) -> Option<String> {
use futures::StreamExt;
let mut total = 0usize;
let mut buf: Vec<u8> = Vec::with_capacity(32 * 1024);
let mut stream = resp.bytes_stream();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.ok()?;
// Defense-in-depth: a single hostile chunk can be arbitrarily
// large (HTTP allows multi-GiB chunks). Reject any one chunk
// bigger than the whole body cap before we even add it to the
// running total — protects against hyper having already
// allocated the chunk on our behalf.
if chunk.len() > RSS_MAX_BYTES {
log::warn!("strawcore::rss single chunk {} exceeds cap; aborting", chunk.len());
return None;
}
total = total.saturating_add(chunk.len());
if total > RSS_MAX_BYTES {
log::warn!("strawcore::rss body exceeded {RSS_MAX_BYTES} bytes; aborting");
return None;
}
buf.extend_from_slice(&chunk);
}
// Lossy decode — A strict from_utf8
// returns None on any invalid byte, so a single mojibake title
// would silently drop the entire channel from the feed. quick-xml
// tolerates U+FFFD replacement chars and the per-entry skip-on-
// empty handles broken entries downstream.
Some(String::from_utf8_lossy(&buf).into_owned())
}
/// Extract the `UCxxx` channel ID from a channel URL. Accepts the
/// shapes the Android app actually has in Subscriptions plus the ones
/// users paste from share intents:
/// * `https://www.youtube.com/channel/UCxxx...`
/// * `https://youtube.com/channel/UCxxx...`
/// * `http(s)://m.youtube.com/channel/UCxxx...`
/// * trailing `/videos`, `?si=...`, etc — anything after the ID is dropped
/// * raw `UCxxx...` (already an ID)
///
/// Real YT channel IDs are EXACTLY 24 chars (`UC` + 22 base64-ish).
///
/// `@handle` URLs are NOT supported here — RSS requires the channel ID.
/// Callers with @handles should resolve via channel_info() once and
/// cache the ID into Subscriptions.
fn extract_channel_id(input: &str) -> Option<String> {
let trimmed = input.trim();
let trimmed_lower = trimmed.to_lowercase();
// Match the "<scheme>://<host>/channel/" prefix in a single sweep
// so we accept http/https + www./m. variants without four-way
// string-strip ladders. ANCHORED at the start of the string —
// prior `find()` accepted any input
// containing the prefix as a substring, so a pasted
// `evil.com/?redir=https://www.youtube.com/channel/UCxxx` would
// silently rewrite to the wrong channel.
const PREFIXES: &[&str] = &[
"https://www.youtube.com/channel/",
"https://youtube.com/channel/",
"https://m.youtube.com/channel/",
"http://www.youtube.com/channel/",
"http://youtube.com/channel/",
"http://m.youtube.com/channel/",
];
for p in PREFIXES {
if let Some(rest) = trimmed_lower.strip_prefix(p) {
// Bytes match 1:1 with `trimmed` since the prefix is ASCII
// and case-folding ASCII doesn't change byte length.
let rest_in_original = &trimmed[p.len()..p.len() + rest.len()];
let id = rest_in_original
.split(|c: char| c == '/' || c == '?' || c == '#')
.next()?;
return validate_channel_id(id);
}
}
validate_channel_id(trimmed)
}
/// A real YouTube channel ID is `UC` followed by exactly 22 chars from
/// `[A-Za-z0-9_-]`.
fn validate_channel_id(id: &str) -> Option<String> {
if id.len() != 24 || !id.starts_with("UC") {
return None;
}
if !id.bytes().skip(2).all(|b| {
matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')
}) {
return None;
}
Some(id.to_string())
}
fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
use quick_xml::events::Event;
use quick_xml::Reader;
let mut reader = Reader::from_str(body);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut items: Vec<SearchItem> = Vec::new();
// Per-entry scratch.
let mut in_entry = false;
let mut depth = 0u8;
let mut video_id = String::new();
let mut title = String::new();
let mut uploader = String::new();
let mut uploader_url = String::new();
let mut thumbnail: Option<String> = None;
let mut published = String::new();
// What text-collecting state we're in. Replaced per element open.
let mut text_target: Option<TextTarget> = None;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let name = e.name();
let local = local_name(name.as_ref());
if local == "entry" {
in_entry = true;
depth = 0;
video_id.clear();
title.clear();
uploader.clear();
uploader_url.clear();
thumbnail = None;
published.clear();
}
if !in_entry {
continue;
}
depth = depth.saturating_add(1);
text_target = match local {
"videoId" => Some(TextTarget::VideoId),
"title" if depth <= 2 => Some(TextTarget::Title),
"name" => Some(TextTarget::UploaderName),
"uri" => Some(TextTarget::UploaderUrl),
"published" => Some(TextTarget::Published),
_ => None,
};
}
Ok(Event::Empty(e)) => {
if !in_entry {
continue;
}
let name = e.name();
let local = local_name(name.as_ref());
// <media:thumbnail url="..."/> is self-closing.
if local == "thumbnail" {
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"url" {
if let Ok(v) = attr.unescape_value() {
thumbnail = Some(v.into_owned());
}
}
}
}
}
Ok(Event::Text(t)) => {
if !in_entry {
continue;
}
let Ok(s) = t.unescape() else { continue };
let s = s.as_ref();
match text_target {
Some(TextTarget::VideoId) => video_id.push_str(s),
Some(TextTarget::Title) => title.push_str(s),
Some(TextTarget::UploaderName) => uploader.push_str(s),
Some(TextTarget::UploaderUrl) => uploader_url.push_str(s),
Some(TextTarget::Published) => published.push_str(s),
None => {}
}
}
Ok(Event::End(e)) => {
if !in_entry {
continue;
}
let name = e.name();
let local = local_name(name.as_ref());
if local == "entry" {
// Skip entries missing the load-bearing fields —
// an empty title renders as a blank card the user
// can't tap, and an empty published collapses the
// recency sort.
if !video_id.is_empty() && !title.is_empty() && !published.is_empty() {
items.push(SearchItem {
url: format!("https://www.youtube.com/watch?v={video_id}"),
title: title.clone(),
uploader: uploader.clone(),
uploader_url: if uploader_url.is_empty() {
Some(format!("https://www.youtube.com/channel/{channel_id}"))
} else {
Some(uploader_url.clone())
},
thumbnail: thumbnail.clone(),
duration_seconds: 0,
view_count: 0,
// RSS gives RFC3339 timestamps. Convert to
// the human-relative format Kotlin's
// recencyScore parser expects ("N units
// ago"). An earlier build was passing the raw ISO
// through, which broke the sort comparator
// — every item tied at MIN_VALUE so the
// feed order was effectively random; LTT +
// WTYP landed at top because they resolved
// first in the fan-out. Caught 2026-05-26.
upload_date_relative: iso_to_relative(&published),
});
if items.len() >= RSS_MAX_ENTRIES {
// Defense-in-depth against a feed that
// ships thousands of <entry> blocks.
return Some(items);
}
}
in_entry = false;
depth = 0;
} else {
depth = depth.saturating_sub(1);
}
text_target = None;
}
Ok(Event::Eof) => break,
// Partial-parse on error: return whatever we've already
// collected rather than throwing the whole batch away.
// A truncated body (EOF mid-stream on a flaky network)
// would otherwise silently disappear the channel.
Err(e) => {
log::warn!("strawcore::rss parse error after {} items: {e}", items.len());
return Some(items);
}
_ => {}
}
buf.clear();
}
Some(items)
}
enum TextTarget {
VideoId,
Title,
UploaderName,
UploaderUrl,
Published,
}
/// Parse an RFC3339 timestamp (`2026-05-25T15:00:00+00:00`) into "N
/// units ago". Drops the timezone offset — YT RSS always serves UTC
/// and the granularity is days at most, so a ±14h skew doesn't matter
/// for the relative display.
///
/// Falls back to the raw string if parsing fails. That keeps the UI
/// readable even on a malformed feed (rare).
fn iso_to_relative(iso: &str) -> String {
let secs = match parse_rfc3339_secs(iso) {
Some(s) => s,
None => return iso.to_string(),
};
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
// A device with a skewed clock can see RSS timestamps as future-
// dated. saturating_sub returns 0 → "0 seconds ago" → sorts to
// top, which is the LTT/WTYP-recurrence vector. Treat future
// dates as "just now" so the relative-string sort behaves and
// a single skewed item doesn't pin itself at the top of the
// feed.
if secs > now_secs {
return "just now".to_string();
}
format_relative(now_secs - secs)
}
fn parse_rfc3339_secs(s: &str) -> Option<i64> {
if s.len() < 19 {
return None;
}
let date = s.get(..10)?;
let time = s.get(11..19)?;
if !s.is_char_boundary(10) || s.as_bytes().get(10) != Some(&b'T') {
return None;
}
let mut date_parts = date.split('-');
let y: i32 = date_parts.next()?.parse().ok()?;
let m: u32 = date_parts.next()?.parse().ok()?;
let d: u32 = date_parts.next()?.parse().ok()?;
let mut time_parts = time.split(':');
let hh: u32 = time_parts.next()?.parse().ok()?;
let mm: u32 = time_parts.next()?.parse().ok()?;
let ss: u32 = time_parts.next()?.parse().ok()?;
// Year clamp BEFORE civil_to_days — out-of-range years overflow
// the era arithmetic in debug, wrap in release. A hostile feed
// serving year=2147483647 must not produce junk timestamps.
if !(YEAR_MIN..=YEAR_MAX).contains(&y) {
return None;
}
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 60 {
return None;
}
let days = civil_to_days(y, m, d);
Some(days * 86_400 + hh as i64 * 3_600 + mm as i64 * 60 + ss as i64)
}
/// Howard Hinnant's days-since-1970-01-01 algorithm. Standard,
/// branch-free, handles negative years correctly. Source: chrono
/// proposal for C++20.
fn civil_to_days(y: i32, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y / 400 } else { (y - 399) / 400 };
let yoe = (y - era * 400) as u32;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era as i64 * 146_097 + doe as i64 - 719_468
}
fn format_relative(age_secs: i64) -> String {
let s = age_secs.max(0);
fn unit(n: i64, name: &str) -> String {
format!("{} {}{} ago", n, name, if n == 1 { "" } else { "s" })
}
if s < 60 {
unit(s, "second")
} else if s < 3_600 {
unit(s / 60, "minute")
} else if s < 86_400 {
unit(s / 3_600, "hour")
} else if s < 604_800 {
unit(s / 86_400, "day")
} else if s < 2_592_000 {
unit(s / 604_800, "week")
} else if s < 31_536_000 {
unit(s / 2_592_000, "month")
} else {
unit(s / 31_536_000, "year")
}
}
/// Strip the namespace prefix off an XML element name. YouTube's feed
/// is heavily namespaced (`yt:videoId`, `media:thumbnail`) but we only
/// care about the local part — namespace-vs-local distinguishing
/// would just bloat the matcher.
fn local_name(qualified: &[u8]) -> &str {
let s = std::str::from_utf8(qualified).unwrap_or("");
match s.rfind(':') {
Some(idx) => &s[idx + 1..],
None => s,
}
}

View file

@ -12,6 +12,7 @@ use std::sync::Once;
mod channel;
mod error;
mod feed;
mod runtime;
mod search;
mod stream;
@ -39,9 +40,12 @@ pub fn init_logging() {
}
/// Smoke-test entry point — round-trip a string through JNI.
/// Used during the initial UniFFI bring-up; kept for future smoke
/// debugging. Logs shape only — the `name` value never hits logcat
/// because a future caller might pass a real user-supplied string.
#[uniffi::export]
pub fn hello_from_rust(name: String) -> String {
log::info!("hello_from_rust called with name={}", name);
log::info!("hello_from_rust called name_len={}", name.len());
format!(
"hello {} from rust 🦀 (strawcore v{})",
name,

View file

@ -1,29 +1,96 @@
// Runtime bootstrap. Called once from Kotlin's StrawApp.onCreate via
// init_logging(). Wires the strawcore-core Downloader + Localization
// singleton so the extractor calls have an HTTP client to use.
// init_logging(), and again before every strawcore call. Wires the
// strawcore-core Downloader + Localization singleton so the extractor
// has an HTTP client to use.
//
// the prior shape used `Once::call_once` and
// silently swallowed errors. If the FIRST call ran while the network
// stack wasn't ready (cold boot in airplane mode, SELinux denial on
// first TLS init, transient resolver failure), the Once slot was
// consumed, NewPipe::init_full never ran, and every subsequent
// search/streamInfo/channelInfo returned DownloaderMissing for the
// rest of the process lifetime.
//
// New shape: use an AtomicBool to track success. Only "success" closes
// the door. On failure we retry — rate-limited so a persistently-broken
// network doesn't hammer reqwest::Client::new() on every call.
use std::sync::{Arc, Once};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use strawcore_core::downloader::ReqwestDownloader;
use strawcore_core::localization::{ContentCountry, Localization};
use strawcore_core::newpipe::NewPipe;
static INIT: Once = Once::new();
static INITIALIZED: AtomicBool = AtomicBool::new(false);
static LAST_ATTEMPT_MS: AtomicU64 = AtomicU64::new(0);
// Guards the actual init attempt so concurrent calls don't all try
// to build the downloader in parallel; serial retry is the goal.
static INIT_LOCK: Mutex<()> = Mutex::new(());
/// Min ms between retries when init has failed. 5s — enough that a
/// hot loop of failed searches doesn't pin a CPU on reqwest setup,
/// short enough that a user who toggled airplane mode off recovers
/// within one tap.
const RETRY_BACKOFF_MS: u64 = 5_000;
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn ensure_initialized() {
INIT.call_once(|| {
match ReqwestDownloader::new() {
Ok(dl) => {
NewPipe::init_full(
Arc::new(dl),
Localization::default(),
ContentCountry::default(),
);
log::info!("strawcore-core: downloader + localization initialized");
}
Err(e) => {
log::error!("strawcore-core: failed to build downloader: {e}");
}
// Fast path: already initialized. Single Acquire load.
if INITIALIZED.load(Ordering::Acquire) {
return;
}
// Backoff check BEFORE the lock — a recent failure shouldn't
// make N concurrent callers queue on a mutex they'll all skip
// out of anyway.
let last = LAST_ATTEMPT_MS.load(Ordering::Acquire);
let now = now_ms();
if last != 0 && now.saturating_sub(last) < RETRY_BACKOFF_MS {
return;
}
// try_lock — if another thread is already mid-init, return
// immediately rather than block. The caller will get
// DownloaderMissing once from the extractor and recover on
// the next user action; the alternative (blocking N tokio
// workers for the full duration of a slow init) freezes the
// UI. was the regression on round-5's
// mutex-first ordering.
let _guard = match INIT_LOCK.try_lock() {
Ok(g) => g,
Err(_) => return,
};
// Re-check under the lock — another thread may have just succeeded.
if INITIALIZED.load(Ordering::Acquire) {
return;
}
match ReqwestDownloader::new() {
Ok(dl) => {
NewPipe::init_full(
Arc::new(dl),
Localization::default(),
ContentCountry::default(),
);
INITIALIZED.store(true, Ordering::Release);
// Clear LAST_ATTEMPT_MS so a future hypothetical
// re-init path (none today) wouldn't see cooldown
// bleed from this success.
LAST_ATTEMPT_MS.store(0, Ordering::Release);
log::info!("strawcore-core: downloader + localization initialized");
}
});
Err(e) => {
// Stamp the timestamp on FAILURE only, so the next
// caller within RETRY_BACKOFF_MS skips, but a successful
// attempt isn't reflected in the backoff state.
LAST_ATTEMPT_MS.store(now, Ordering::Release);
log::error!("strawcore-core: downloader init failed (will retry on next call)");
let _ = e;
}
}
}

View file

@ -20,6 +20,10 @@ pub struct SearchItem {
pub duration_seconds: i64,
/// Reported view count. 0 = unknown.
pub view_count: i64,
/// Relative upload date as YT renders it ("2 days ago", "3 weeks
/// ago"). Empty if not extracted. Strawcore-core already populates
/// this on StreamInfoItem; we just pass it through.
pub upload_date_relative: String,
}
pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
@ -44,12 +48,23 @@ pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
} else {
item.view_count
},
upload_date_relative: item.upload_date_relative,
}
}
#[uniffi::export(async_runtime = "tokio")]
pub async fn search(query: String) -> Result<Vec<SearchItem>, StrawcoreError> {
log::info!("strawcore::search query={}", query);
// Don't log the query itself — searches are PII (sometimes
// names, sometimes embarrassing) and android_logger emits at
// info-level in release builds, which means they'd ride the
// Settings → Export Logs path straight into a user's chat. Log
// shape, not content.
log::info!("strawcore::search query_len={}", query.len());
// ensure_initialized was only wired into
// init_logging() so the 5s-backoff retry path never fired from
// the hot entry points. Now every extractor entry re-asserts
// — cheap when INITIALIZED is true (single Acquire load).
crate::runtime::ensure_initialized();
let result = tokio::task::spawn_blocking(move || {
search_extractor::search(&query, SearchFilter::Videos)
})

View file

@ -57,7 +57,8 @@ pub struct AudioStreamItem {
#[uniffi::export(async_runtime = "tokio")]
pub async fn stream_info(input: String) -> Result<StreamInfo, StrawcoreError> {
log::info!("strawcore::stream_info input={}", input);
log::info!("strawcore::stream_info input_len={}", input.len());
crate::runtime::ensure_initialized();
let video_id = resolve_video_id(&input)?;
let video_id_for_call = video_id.clone();
let core = tokio::task::spawn_blocking(move || core_stream_info(&video_id_for_call))

View file

@ -39,13 +39,30 @@ configure<ApplicationExtension> {
}
buildTypes {
// R8 enabled on BOTH variants — we publish the debug APK to
// fdroid (com.sulkta.straw.debug) per the existing pipeline,
// and audit-flagged Log.d strips depended on R8 actually
// running on the variant we ship. Keep rules in
// strawApp/proguard-rules.pro cover UniFFI + JNA +
// kotlinx-serialization companions.
debug {
isDebuggable = true
applicationIdSuffix = ".debug"
resValue("string", "app_name", "Straw debug")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
release {
isMinifyEnabled = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
@ -81,7 +98,7 @@ dependencies {
implementation(libs.jetbrains.compose.foundation)
implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.ui)
implementation("androidx.compose.material:material-icons-core:1.7.5")
implementation("androidx.compose.material:material-icons-extended:1.7.5")
// Lifecycle + ViewModel for Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
@ -95,7 +112,9 @@ dependencies {
implementation(libs.coil.network.okhttp)
// NewPipeExtractor (JVM/Android-only) + its OkHttp dep
implementation(libs.newpipe.extractor)
// libs.newpipe.extractor — REMOVED in Path C-6. Extractor is now strawcore
// (Rust + rustypipe via UniFFI). See rust/strawcore/ + the cargoBuild +
// uniffiBindgen Gradle tasks below.
implementation(libs.squareup.okhttp)
// JSON for SponsorBlock + Return YouTube Dislike clients
@ -110,4 +129,101 @@ dependencies {
implementation("androidx.media3:media3-session:1.4.1")
// Guava ListenableFuture support for awaiting MediaController connect.
implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0")
// WorkManager — periodic background poll of fdroid.sulkta.com index
// for self-update notifications. CoroutineWorker is built into the
// base work-runtime artifact as of 2.10.
implementation(libs.androidx.work.runtime)
// strawcore — Rust YouTube extractor via UniFFI/JNA. Built by the
// cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search /
// streamInfo / channelInfo to replace NewPipeExtractor.
implementation("net.java.dev.jna:jna:5.14.0@aar")
}
// =============================================================================
// Phase U-1 / Path-C-2 — Rust core build glue.
//
// Two tasks chain into the Android build:
// cargoBuild — cross-compiles rust/strawcore for the four Android ABIs
// via cargo-ndk and drops the .so files in strawApp/src/main/jniLibs/.
// uniffiBindgen — generates the Kotlin bindings from the freshly-built lib
// into strawApp/src/main/java/uniffi/strawcore/.
//
// Both depend on:
// - cargo + rustup with the four Android targets installed
// - cargo-ndk on PATH
// - ANDROID_NDK_HOME pointing at an NDK with the right toolchains
// All of that lives in the Sulkta build container.
// =============================================================================
val rustRoot = file("../rust").absolutePath
val jniLibsDir = file("src/main/jniLibs").absolutePath
val bindingsDir = file("src/main/java").absolutePath
val cargoHome: String = System.getenv("CARGO_HOME") ?: "/caches/cargo"
val cargoBin: String = "$cargoHome/bin/cargo"
val ndkHome: String = System.getenv("ANDROID_NDK_HOME")
?: System.getenv("ANDROID_NDK_ROOT")
?: "/caches/android-sdk/ndk/27.2.12479018"
// Honor CARGO_TARGET_DIR if set (our build container redirects it to a
// cache mount because the container's writable rootfs hits 100% before
// the cross-compile for 4 ABIs finishes). Falls back to the default
// `<workspace>/target`.
val cargoTargetDir: String = System.getenv("CARGO_TARGET_DIR")
?: "$rustRoot/target"
val cargoBuild by tasks.registering(Exec::class) {
group = "rust"
description = "Cross-compile strawcore for all Android ABIs via cargo-ndk."
workingDir = file(rustRoot)
environment("ANDROID_NDK_HOME", ndkHome)
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
commandLine = listOf(
cargoBin, "ndk",
"-t", "arm64-v8a",
"-t", "armeabi-v7a",
"-t", "x86",
"-t", "x86_64",
"-o", jniLibsDir,
"build", "--release", "-p", "strawcore",
)
standardOutput = System.out
errorOutput = System.err
}
val cargoBuildHost by tasks.registering(Exec::class) {
group = "rust"
description = "Build host-arch debug strawcore so bindgen can read its UniFFI metadata."
workingDir = file(rustRoot)
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
commandLine = listOf(cargoBin, "build", "-p", "strawcore")
standardOutput = System.out
errorOutput = System.err
}
val uniffiBindgen by tasks.registering(Exec::class) {
group = "rust"
description = "Generate Kotlin bindings for strawcore via uniffi-bindgen."
dependsOn(cargoBuildHost)
workingDir = file(rustRoot)
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
commandLine = listOf(
cargoBin, "run", "--quiet", "--bin", "uniffi-bindgen", "--",
"generate",
"--library", "$cargoTargetDir/debug/libstrawcore.so",
"--crate", "strawcore",
"--language", "kotlin",
"--no-format",
"--out-dir", bindingsDir,
)
standardOutput = System.out
errorOutput = System.err
}
// Make sure Android's JNI-libs merge picks up the freshly built .so files,
// and Kotlin compilation can resolve the generated bindings.
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }
.configureEach { dependsOn(cargoBuild) }
tasks.matching { it.name.startsWith("compile") && it.name.endsWith("Kotlin") }
.configureEach { dependsOn(uniffiBindgen) }

90
strawApp/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,90 @@
# SPDX-FileCopyrightText: 2026 Sulkta-Coop
# SPDX-License-Identifier: GPL-3.0-or-later
#
# R8 keep rules for the Straw app module. The legacy `app/proguard-rules.pro`
# is for the upstream NewPipe module different namespaces, different
# rules. This file is OURS.
#
# AGP's getDefaultProguardFile("proguard-android-optimize.txt") handles
# the Android framework + AndroidX + Compose runtime defaults via
# consumer rules shipped with each library. We only need to spell out
# what those defaults can't see:
#
# * UniFFI bindings reflective FFI dispatch from generated code.
# * JNA reflects on every class extending com.sun.jna.Library
# (that's how the loadLibrary glue works).
# * Our kotlinx-serialization @Serializable types their generated
# $$serializer companions get tree-shaken without explicit keeps.
# * Media3 session metadata Parcelables.
# -- UniFFI -------------------------------------------------------------
# Generated bindings live under uniffi.strawcore.*. The Rust side calls
# them via JNI symbol name; if R8 renames the class or methods, every
# extractor call NPEs.
-keep class uniffi.strawcore.** { *; }
-keep class uniffi.** { *; }
# -- JNA ---------------------------------------------------------------
# JNA looks up Library subclasses by Class.forName + reflection at
# load time. Anything that extends Library or has @FieldOrder must
# survive.
-keep class * extends com.sun.jna.Library { *; }
-keep class com.sun.jna.** { *; }
-dontwarn com.sun.jna.**
# -- kotlinx-serialization ---------------------------------------------
# Every @Serializable type gets a synthetic Companion + $$serializer
# class. R8 will strip the $$serializer if nothing visibly calls it
# (the lookup goes through reflection on the Companion).
-keepattributes *Annotation*, InnerClasses
-dontwarn kotlinx.serialization.**
-keep,includedescriptorclasses class com.sulkta.straw.**$$serializer { *; }
-keepclassmembers class com.sulkta.straw.** {
*** Companion;
}
-keepclasseswithmembers class com.sulkta.straw.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Same dance for our top-level @Serializable types defined outside
# `com.sulkta.straw.**` (Rust DTOs, etc.). Belt + suspenders.
-keepclassmembers @kotlinx.serialization.Serializable class * {
static **$Companion Companion;
public static <1>$Companion Companion;
}
-keepclasseswithmembers @kotlinx.serialization.Serializable class * {
kotlinx.serialization.KSerializer serializer(...);
}
-keep class **$$serializer { *; }
# -- Media3 / ExoPlayer ------------------------------------------------
# Most of Media3 ships consumer rules but session-related Parcelables
# are reflectively reconstructed across process boundaries (the
# MediaController talks to PlaybackService via Binder). Keep their
# field names.
-keep class androidx.media3.session.** { *; }
-keep class androidx.media3.common.MediaItem { *; }
-keep class androidx.media3.common.MediaItem$* { *; }
-keep class androidx.media3.common.MediaMetadata { *; }
# -- Strawcore exceptions / DTOs reflected by UniFFI --------------------
# StrawcoreError is a sealed Throwable hierarchy exposed via UniFFI.
# Keep all subclasses + their fields so the Kotlin pattern-match works
# after minification.
-keep class com.sulkta.straw.feature.player.** { *; }
# -- Reflection-via-Class.forName paths from Compose --------------------
# Compose's runtime does some Class.forName for its own bootstrap; the
# AGP consumer rules cover this, but documenting the dependency here
# so a future bump doesn't surprise us.
-keep class androidx.compose.runtime.** { *; }
# -- WorkManager Worker classes ----------------------------------------
# WorkManager instantiates Worker subclasses by class name via
# reflection (`Class.forName(workerSpec.workerClassName)`). If R8
# renames our UpdateCheckWorker the scheduler enqueues it but the
# instantiation fails silently and no checks ever run.
-keep class com.sulkta.straw.feature.update.UpdateCheckWorker { *; }
-keep class com.sulkta.straw.feature.feed.FeedRefreshWorker { *; }
-keep class * extends androidx.work.ListenableWorker { *; }

View file

@ -11,12 +11,20 @@
<!-- Wake while audio plays -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- DownloadManager Request.setNotificationVisibility(HIDDEN) requires
this permission. Used by Downloader so signed googlevideo URLs
don't surface in the system notification shade. -->
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<application
android:name=".StrawApp"
android:label="@string/app_name"
android:icon="@android:drawable/sym_def_app_icon"
android:roundIcon="@android:drawable/sym_def_app_icon"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
@ -29,7 +37,12 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Open YouTube URLs with Straw. -->
<!-- Open YouTube URLs with Straw. Hosts here must stay in sync
with ALLOWED_YT_HOSTS in util/YtUrl.kt (canonical home).
Was previously inlined in StrawActivity.kt under YT_HOSTS;
the two lists drifted (music.youtube.com etc. accepted by
code but never offered by the launcher disambig), so the
canonical list lives in one place now. -->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@ -39,6 +52,9 @@
<data android:host="m.youtube.com" />
<data android:host="youtube.com" />
<data android:host="youtu.be" />
<data android:host="music.youtube.com" />
<data android:host="youtube-nocookie.com" />
<data android:host="www.youtube-nocookie.com" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
@ -47,11 +63,11 @@
</intent-filter>
</activity>
<!-- Phase M-2 / S: MediaSessionService for background audio + notification + lock-screen
controls. Marked NOT exported (audit CRIT-2): any installed app can otherwise
craft an Intent with the MediaSessionService action and drive playback from
attacker-controlled URLs. The intent-filter stays so the Media3 session router
can find the service within our own process. -->
<!-- MediaSessionService for background audio + notification + lock-screen
controls. Marked NOT exported: otherwise any installed app could
craft an Intent with the MediaSessionService action and drive playback
from attacker-controlled URLs. The intent-filter stays so the Media3
session router can find the service within our own process. -->
<service
android:name=".feature.player.PlaybackService"
android:exported="false"
@ -60,5 +76,16 @@
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
<!-- FileProvider for sharing log dumps from Settings → Export logs. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Tiny in-app nav model sealed Screen + a stack. No nav library; pure
* state. Good enough for day-2's home search detail player flow.
* state.
*/
package com.sulkta.straw
@ -16,9 +16,12 @@ sealed interface Screen {
data object Home : Screen
data object Search : Screen
data object Settings : Screen
data object Playlists : Screen
data object Downloads : Screen
data class VideoDetail(val streamUrl: String, val title: String) : Screen
data class Player(val streamUrl: String, val title: String) : Screen
data class Channel(val channelUrl: String, val name: String) : Screen
data class PlaylistView(val playlistId: String, val name: String) : Screen
}
class Navigator(initial: Screen) {
@ -29,12 +32,27 @@ class Navigator(initial: Screen) {
stack.add(s)
}
/** @return false if we couldn't pop (root), true otherwise. */
/**
* Pop the current screen off the stack. Returns false at root so the
* caller can defer to the system back behavior (exit the app); true
* otherwise.
*/
fun pop(): Boolean {
if (stack.size <= 1) return false
stack.removeAt(stack.lastIndex)
return true
}
/**
* Replace the entire stack with a single screen. Used by the
* swipe-to-minimize gesture when the user lands directly on a video
* page via a deep link there's nothing to pop back to, so we drop
* them on Home instead.
*/
fun resetTo(s: Screen) {
stack.clear()
stack.add(s)
}
}
@Composable

View file

@ -12,29 +12,54 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.media3.common.util.UnstableApi
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.ThemeMode
import com.sulkta.straw.feature.channel.ChannelScreen
import com.sulkta.straw.feature.detail.VideoDetailScreen
import com.sulkta.straw.feature.download.DownloadsScreen
import com.sulkta.straw.feature.player.LocalStrawController
import com.sulkta.straw.feature.player.MinibarOverlay
import com.sulkta.straw.feature.player.NowPlaying
import com.sulkta.straw.feature.player.PlayerScreen
import com.sulkta.straw.feature.player.SponsorBlockSkipLoop
import com.sulkta.straw.feature.player.rememberStrawController
import com.sulkta.straw.feature.playlist.PlaylistViewScreen
import com.sulkta.straw.feature.playlist.PlaylistsScreen
import com.sulkta.straw.feature.search.SearchScreen
import com.sulkta.straw.feature.settings.SettingsScreen
import kotlinx.coroutines.flow.MutableStateFlow
private val YT_HOSTS = setOf(
"youtube.com", "www.youtube.com", "m.youtube.com",
"music.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com",
"youtu.be",
)
// Allowlist now lives in util/YtUrl.kt with extra hardening (scheme
// requirement, trailing-dot strip). The prior shape duplicated the
// host set here and would drift away from the util.
private val YT_URL_RE = Regex(
"https?://(?:www\\.|m\\.|music\\.)?(?:youtube(?:-nocookie)?\\.com/[A-Za-z0-9_/?=&\\-.%]+|youtu\\.be/[A-Za-z0-9_\\-]+)",
)
class StrawActivity : ComponentActivity() {
/**
* Newly-arrived deep-link URL while the activity is already running.
* `onNewIntent` writes here; the Compose tree observes and pushes a
* VideoDetail screen. Without this the singleTask flag silently drops
* every share-to-Straw after the first.
*/
private val pendingDeepLink = MutableStateFlow<String?>(null)
@OptIn(UnstableApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@ -42,8 +67,22 @@ class StrawActivity : ComponentActivity() {
val startUrl = pickYouTubeUrl(intent)
setContent {
val scheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
// Theme picker: System follows OS, Light/Dark force the
// matching scheme regardless of system setting.
val themeMode by Settings.get().themeMode.collectAsState()
val systemDark = isSystemInDarkTheme()
val dark = when (themeMode) {
ThemeMode.System -> systemDark
ThemeMode.Light -> false
ThemeMode.Dark -> true
}
val scheme = if (dark) strawDarkColors() else strawLightColors()
// One MediaController for the whole activity. Every screen pulls
// it via LocalStrawController; the minibar overlay below uses it
// too. Single player, single source of truth.
val controller = rememberStrawController()
MaterialTheme(colorScheme = scheme) {
CompositionLocalProvider(LocalStrawController provides controller) {
Surface(modifier = Modifier.fillMaxSize()) {
val initial: Screen =
if (startUrl != null) Screen.VideoDetail(startUrl, "") else Screen.Home
@ -62,71 +101,126 @@ class StrawActivity : ComponentActivity() {
onDispose { cb.remove() }
}
when (val s = nav.current) {
is Screen.Home -> StrawHome(
onOpenSearch = { nav.push(Screen.Search) },
onOpenSettings = { nav.push(Screen.Settings) },
onOpenVideo = { url, title ->
nav.push(Screen.VideoDetail(url, title))
},
onOpenChannel = { url, name ->
nav.push(Screen.Channel(url, name))
},
)
is Screen.Settings -> SettingsScreen()
is Screen.Search -> SearchScreen(
onOpenVideo = { url, title ->
nav.push(Screen.VideoDetail(url, title))
},
)
is Screen.VideoDetail -> VideoDetailScreen(
streamUrl = s.streamUrl,
initialTitle = s.title,
onPlay = {
nav.push(Screen.Player(s.streamUrl, s.title))
},
onOpenChannel = { url, name ->
nav.push(Screen.Channel(url, name))
},
onOpenVideo = { url, title ->
nav.push(Screen.VideoDetail(url, title))
},
)
is Screen.Channel -> ChannelScreen(
channelUrl = s.channelUrl,
initialName = s.name,
onOpenVideo = { url, title ->
nav.push(Screen.VideoDetail(url, title))
},
)
is Screen.Player -> PlayerScreen(
streamUrl = s.streamUrl,
title = s.title,
)
// Drain newly-arrived deep links. Consumed (cleared) once
// pushed so we don't re-navigate on every recomposition.
val pending by pendingDeepLink.collectAsState()
LaunchedEffect(pending) {
val url = pending ?: return@LaunchedEffect
nav.push(Screen.VideoDetail(url, ""))
pendingDeepLink.value = null
}
// SponsorBlock skip loop runs at the activity level so it
// applies whether the user is fullscreen, in the minibar,
// or away from the player surface.
SponsorBlockSkipLoop()
Box(modifier = Modifier.fillMaxSize()) {
ScreenContent(nav, s = nav.current)
// The minibar is the takeover-when-you-leave UI:
// hide it while you're on the actual video page
// (the inline player IS the player) and hide it
// in fullscreen (which IS the player). Everywhere
// else, audio keeps going and the minibar gives
// you a way back.
val cur = nav.current
if (cur !is Screen.Player && cur !is Screen.VideoDetail) {
MinibarOverlay(
onExpand = {
val item = NowPlaying.current.value ?: return@MinibarOverlay
nav.push(Screen.VideoDetail(item.streamUrl, item.title))
},
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
}
}
}
}
}
/** Pull a YouTube URL out of an incoming Intent (VIEW or SEND). */
/**
* `launchMode="singleTask"` means a fresh VIEW/SEND from Chrome lands
* on the already-running activity instead of creating a new instance.
* Forward the URL into the Compose tree via the pending-link flow.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
pickYouTubeUrl(intent)?.let { pendingDeepLink.value = it }
}
@Composable
private fun ScreenContent(nav: Navigator, s: Screen) {
when (s) {
is Screen.Home -> StrawHome(
onOpenSearch = { nav.push(Screen.Search) },
onOpenSettings = { nav.push(Screen.Settings) },
onOpenPlaylists = { nav.push(Screen.Playlists) },
onOpenDownloads = { nav.push(Screen.Downloads) },
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
)
is Screen.Downloads -> DownloadsScreen()
is Screen.Settings -> SettingsScreen()
is Screen.Search -> SearchScreen(
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
)
is Screen.VideoDetail -> VideoDetailScreen(
streamUrl = s.streamUrl,
initialTitle = s.title,
onPlay = { nav.push(Screen.Player(s.streamUrl, s.title)) },
onMinimize = { if (!nav.pop()) nav.resetTo(Screen.Home) },
onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
)
is Screen.Channel -> ChannelScreen(
channelUrl = s.channelUrl,
initialName = s.name,
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
)
is Screen.Player -> PlayerScreen(
streamUrl = s.streamUrl,
title = s.title,
onMinimize = { nav.pop() },
)
is Screen.Playlists -> PlaylistsScreen(
onOpenPlaylist = { id, name -> nav.push(Screen.PlaylistView(id, name)) },
)
is Screen.PlaylistView -> PlaylistViewScreen(
playlistId = s.playlistId,
initialName = s.name,
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
)
}
}
/** Pull a YouTube URL out of an incoming VIEW or SEND intent. */
private fun pickYouTubeUrl(intent: Intent?): String? {
intent ?: return null
return when (intent.action) {
Intent.ACTION_VIEW -> {
val data = intent.data?.toString() ?: return null
// Explicit scheme + host check — defense in depth vs the
// manifest intent-filter (apps can synth intents that
// bypass filter scheme matching when activity is exported).
if (intent.scheme?.lowercase() !in setOf("https", "http")) return null
// manifest intent-filter; apps can synth intents that
// bypass filter scheme matching on exported activities.
// HTTPS only — matches the manifest VIEW filter so an explicit
// ComponentName intent can't smuggle an http:// URL past the
// filter check. Defense in depth; the YT_URL_RE still allows
// http for the ACTION_SEND substring case where the URL is
// embedded in attacker-controlled text and we want to match
// common share-sheet links, but VIEW must be tighter.
if (intent.scheme?.lowercase() != "https") return null
if (!looksLikeYouTube(data)) return null
data
}
Intent.ACTION_SEND -> {
val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null
// Regex extracts a YT-looking substring from arbitrary
// attacker-controlled text. Re-validate via URI parse + host
// check before we hand it to NewPipeExtractor.
// Extract a YT-looking substring from attacker-controlled
// text, then re-validate via URI parse + host check before
// handing it to the extractor.
val candidate = YT_URL_RE.find(shared)?.value ?: return null
val truncated = candidate.substringBefore('#').trim()
if (!looksLikeYouTube(truncated)) return null
@ -136,8 +230,6 @@ class StrawActivity : ComponentActivity() {
}
}
private fun looksLikeYouTube(url: String): Boolean {
val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return false
return host.lowercase() in YT_HOSTS
}
private fun looksLikeYouTube(url: String): Boolean =
com.sulkta.straw.util.isAllowedYtUrl(url)
}

View file

@ -6,24 +6,98 @@
package com.sulkta.straw
import android.app.Application
import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.FeedEnrichment
import com.sulkta.straw.data.History
import com.sulkta.straw.data.Playlists
import com.sulkta.straw.data.Resume
import com.sulkta.straw.data.SearchCache
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.extractor.NewPipeDownloader
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.localization.ContentCountry
import org.schabi.newpipe.extractor.localization.Localization
import com.sulkta.straw.feature.dataimport.SettingsImport
import com.sulkta.straw.feature.feed.FeedRefreshScheduler
import com.sulkta.straw.feature.update.UpdateScheduler
import com.sulkta.straw.feature.update.runUpdateCheck
import com.sulkta.straw.util.strawLogW
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class StrawApp : Application() {
/**
* App-scoped coroutine scope for one-time startup work that
* shouldn't tie up Application.onCreate. SupervisorJob so a failure
* in one launch doesn't cascade. CoroutineExceptionHandler so an
* uncaught throwable in a top-level launch doesn't crash the
* process on cold start (would otherwise hit the default handler
* even with SupervisorJob).
*/
private val appScope = CoroutineScope(
SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, t ->
strawLogW("StrawApp") { "appScope uncaught: ${t.javaClass.simpleName}: ${t.message}" }
},
)
companion object {
/** Process-scoped coroutine scope survives Composition + ViewModel
* teardown. Use for fire-and-forget work like long-press
* "Add to queue" that needs to outlive the UI surface that
* triggered it. */
lateinit var globalScope: CoroutineScope
private set
}
init {
// The companion lateinit guarantees the same StrawApp instance
// is the only one that sets globalScope — Application is a
// process-singleton on Android.
globalScope = appScope
}
override fun onCreate() {
super.onCreate()
NewPipe.init(
NewPipeDownloader.init(),
Localization("en", "US"),
ContentCountry("US"),
)
History.init(this)
// Path C-7: route Rust `log::*` calls into Android logcat under tag
// "strawcore". Without this, every log line emitted from rustypipe /
// strawcore is silently dropped, making playback regressions invisible
// from `adb logcat`.
uniffi.strawcore.initLogging()
// Small + universally-accessed stores: synchronous init.
// Settings is a handful of SP keys (read on first compose for
// themeMode), History caps at 50 watches + 20 searches,
// Subscriptions is a single channel list — sub-millisecond
// cost on cold cache.
Settings.init(this)
History.init(this)
Subscriptions.init(this)
Playlists.init(this)
Resume.init(this)
FeedEnrichment.init(this)
// FeedCache (~225 KB) + SearchCache
// (~150 KB) JSON-decode at construction. Stash the
// applicationContext eagerly (cheap) so `get()` is callable
// anywhere; the actual store construction (and the disk
// decode that goes with it) is lazy. ViewModels accessing
// these on IO trigger the construction there — never on the
// main thread.
FeedCache.init(this)
SearchCache.init(this)
// sweepStale's deleteRecursively
// can walk ~256 MB if a previous import was LMK-killed
// mid-extraction. Strictly off the main thread.
appScope.launch {
SettingsImport.sweepStale(this@StrawApp)
}
// Auto-update polling. Schedule the periodic worker if enabled,
// then kick a fresh check on cold start so users don't wait a
// full interval to find out about a pending update.
UpdateScheduler.applyFromSettings(this)
if (Settings.get().autoUpdateCheck.value) {
appScope.launch { runUpdateCheck(this@StrawApp) }
}
// Background subs feed refresh — opt-in periodic WorkManager
// job that pre-warms FeedCache so cold open paints fresh.
FeedRefreshScheduler.applyFromSettings(this)
}
}

View file

@ -8,8 +8,12 @@
package com.sulkta.straw
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -22,13 +26,22 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlaylistPlay
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -36,23 +49,23 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -67,7 +80,11 @@ import com.sulkta.straw.data.History
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
import com.sulkta.straw.feature.player.VideoThumbnail
import com.sulkta.straw.feature.playlist.VideoActionTarget
import com.sulkta.straw.feature.playlist.VideoActionsSheet
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.rememberBottomContentPadding
import com.sulkta.straw.util.formatViews
import kotlinx.coroutines.launch
@ -78,6 +95,8 @@ private enum class HomeView { Subs, History }
fun StrawHome(
onOpenSearch: () -> Unit,
onOpenSettings: () -> Unit,
onOpenPlaylists: () -> Unit,
onOpenDownloads: () -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
onOpenChannel: (channelUrl: String, name: String) -> Unit,
feedVm: SubscriptionFeedViewModel = viewModel(),
@ -107,7 +126,7 @@ fun StrawHome(
NavigationDrawerItem(
label = { Text("Subscriptions") },
icon = { Text("👤") },
icon = { Icon(Icons.Filled.Person, contentDescription = null) },
selected = view == HomeView.Subs,
onClick = {
view = HomeView.Subs
@ -117,7 +136,7 @@ fun StrawHome(
)
NavigationDrawerItem(
label = { Text("History") },
icon = { Text("📺") },
icon = { Icon(Icons.Filled.History, contentDescription = null) },
selected = view == HomeView.History,
onClick = {
view = HomeView.History
@ -125,10 +144,30 @@ fun StrawHome(
},
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text("Playlists") },
icon = { Icon(Icons.Filled.PlaylistPlay, contentDescription = null) },
selected = false,
onClick = {
scope.launch { drawerState.close() }
onOpenPlaylists()
},
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text("Downloads") },
icon = { Icon(Icons.Filled.Download, contentDescription = null) },
selected = false,
onClick = {
scope.launch { drawerState.close() }
onOpenDownloads()
},
modifier = Modifier.padding(horizontal = 12.dp),
)
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
NavigationDrawerItem(
label = { Text("Settings") },
icon = { Text("") },
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
selected = false,
onClick = {
scope.launch { drawerState.close() }
@ -141,42 +180,33 @@ fun StrawHome(
) {
Scaffold(
topBar = {
// Green-tinted bar inspired by NewPipe/Tubular's colored
// header, but using our forest-green primary container so
// it sits cleanly with the rest of the Material 3 surfaces.
TopAppBar(
title = {
// Search-pill in the title slot — tap takes you to the
// full search screen with the field auto-focused. Same
// idea as YT's mobile top bar.
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(end = 8.dp)
.height(40.dp)
.clip(RoundedCornerShape(20.dp))
.clickable(onClick = onOpenSearch),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 14.dp),
) {
Text(
"🔍",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.width(10.dp))
Text(
"Search YouTube",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
"straw",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
},
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = "Menu")
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(Icons.Filled.Search, contentDescription = "Search")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary,
),
)
},
) { padding ->
@ -208,6 +238,10 @@ fun StrawHome(
@Composable
private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) {
val watches by History.get().watches.collectAsState()
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
actionTarget?.let { t ->
VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
}
Column {
Text(
@ -224,9 +258,20 @@ private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn {
LazyColumn(contentPadding = rememberBottomContentPadding()) {
items(watches) { w ->
RecentRow(w) { onOpenVideo(w.url, w.title) }
RecentRow(
item = w,
onClick = { onOpenVideo(w.url, w.title) },
onLongClick = {
actionTarget = VideoActionTarget(
streamUrl = w.url,
title = w.title,
uploader = w.uploader,
thumbnail = w.thumbnail,
)
},
)
HorizontalDivider()
}
}
@ -242,8 +287,49 @@ private fun SubsPane(
) {
val subs by Subscriptions.get().subs.collectAsState()
val feed by feedVm.ui.collectAsState()
val watches by History.get().watches.collectAsState()
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
actionTarget?.let { t ->
VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
}
LaunchedEffect(subs) { feedVm.refreshIfStale() }
// Filter + pagination state. hideWatched is sticky for the session
// (no SharedPreferences yet — easy to add if persistence is wanted).
// visibleCount starts at PAGE_SIZE and grows by PAGE_SIZE every time
// the scroll passes ~5 items from the bottom of what's currently
// visible.
var hideWatched by remember { mutableStateOf(false) }
var visibleCount by remember { mutableIntStateOf(PAGE_SIZE) }
// O(1) lookup for the watched-filter; rebuild only when watches
// change. Drop blank IDs — `recordWatch` doesn't gate on those,
// and a blank in the set would `extractVideoId(url)=""` match
// EVERY malformed-URL item and silently hide them all.
val watchedIds = remember(watches) {
watches.map { it.videoId }.filter { it.isNotBlank() }.toSet()
}
val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState()
val filteredItems = remember(feed.items, hideWatched, watchedIds, hideShorts) {
val watchFiltered = if (!hideWatched) feed.items
else feed.items.filterNot { extractVideoId(it.url) in watchedIds }
com.sulkta.straw.util.applyContentFilters(watchFiltered, hideShorts = hideShorts)
}
// Reset pagination when the underlying list changes so the user
// doesn't end up looking at "no more items" after a refresh.
LaunchedEffect(filteredItems) {
if (visibleCount > filteredItems.size.coerceAtLeast(PAGE_SIZE)) {
visibleCount = PAGE_SIZE
}
}
// remember the page-slice so we don't allocate a new ArrayList on
// every recomposition (scroll hitch).
val displayed = remember(filteredItems, visibleCount) {
filteredItems.take(visibleCount)
}
val hasMore = filteredItems.size > visibleCount
Column {
if (subs.isEmpty()) {
Text(
@ -267,6 +353,13 @@ private fun SubsPane(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
FilterChip(
selected = hideWatched,
onClick = { hideWatched = !hideWatched },
label = { Text("Hide watched") },
colors = FilterChipDefaults.filterChipColors(),
)
Spacer(modifier = Modifier.width(8.dp))
TextButton(onClick = { feedVm.refresh() }) {
Text(if (feed.loading) "..." else "Refresh")
}
@ -280,7 +373,7 @@ private fun SubsPane(
Spacer(modifier = Modifier.height(16.dp))
// Show a slim error banner above cached items even if we have data —
// audit HIGH-7: previously a 401/429 looked identical to a successful
// previously a 401/429 looked identical to a successful
// refresh because the error chip was hidden whenever items != empty.
if (feed.error != null && feed.items.isNotEmpty()) {
Text(
@ -309,34 +402,127 @@ private fun SubsPane(
color = MaterialTheme.colorScheme.error,
)
}
feed.items.isNotEmpty() && filteredItems.isEmpty() -> {
Text(
"All caught up — nothing unwatched.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {
LazyColumn {
items(feed.items) { item ->
FeedRow(item) { onOpenVideo(item.url, item.title) }
val listState = rememberLazyListState()
// Bump visibleCount when the user scrolls within 5 items
// of the current bottom. snapshotFlow + derivedStateOf
// keeps this off the per-frame recompose path.
val nearBottom by remember {
derivedStateOf {
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
lastVisible >= info.totalItemsCount - 5
}
}
// Key on listState only — the previous key set
// (displayed.size, hasMore) was mutated BY this effect,
// which cancelled the snapshotFlow collector mid-stream
// and produced the "scrolled to bottom, nothing loads"
// bug from the audit.
//
// hasMore and filteredItems are read inside the
// snapshotFlow producer (not closed over from outside)
// so Compose re-reads them on each frame instead of
// capturing the stale value at lambda-creation time.
val filteredCount = filteredItems.size
LaunchedEffect(listState, filteredCount) {
snapshotFlow {
nearBottom && visibleCount < filteredCount
}.collect { shouldGrow ->
if (shouldGrow) {
visibleCount = (visibleCount + PAGE_SIZE)
.coerceAtMost(filteredCount)
}
}
}
LazyColumn(
state = listState,
contentPadding = rememberBottomContentPadding(),
) {
items(
items = displayed,
key = { it.url },
) { item ->
FeedRow(
item = item,
onClick = { onOpenVideo(item.url, item.title) },
onLongClick = {
actionTarget = VideoActionTarget(
streamUrl = item.url,
title = item.title,
uploader = item.uploader,
thumbnail = item.thumbnail,
)
},
)
HorizontalDivider()
}
if (hasMore) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
"Loading more...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
private const val PAGE_SIZE = 20
/**
* Extract the YouTube video ID from a watch URL so we can cross-check
* against History.watches (which stores videoId, not full URL). Handles
* the common forms: youtube.com/watch?v=XXXXXXXXXXX and youtu.be/X...
* Returns empty string when nothing matches callers compare against
* watchedIds, so an empty string just won't filter anything out.
*/
private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&#].*)?$")
private fun extractVideoId(url: String): String =
VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1).orEmpty()
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
private fun FeedRow(
item: StreamItem,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.Top,
) {
AsyncImage(
model = item.thumbnail,
contentDescription = null,
VideoThumbnail(
thumbnail = item.thumbnail,
videoUrl = item.url,
durationSeconds = item.durationSeconds,
modifier = Modifier
.width(140.dp)
.height(80.dp)
.clip(RoundedCornerShape(6.dp)),
.height(80.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
@ -355,6 +541,10 @@ private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
append(" · ")
append(formatViews(item.viewCount))
}
if (item.uploadDateRelative.isNotBlank()) {
append(" · ")
append(item.uploadDateRelative)
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@ -376,37 +566,71 @@ private fun SubChip(
.clickable { onOpenChannel(ch.url, ch.name) },
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImage(
model = ch.avatar,
contentDescription = null,
modifier = Modifier.size(56.dp).clip(CircleShape),
)
if (ch.avatar.isNullOrBlank()) {
// Lettered fallback — strawcore can return a null avatar
// when the channel header layout doesn't include one (more
// common on smaller channels). Feed-fetch backfills this
// asynchronously via Subscriptions.updateAvatar, but until
// it arrives we still want SOMETHING visible.
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = ch.name.firstOrNull()?.uppercase().orEmpty(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold,
)
}
} else {
AsyncImage(
model = ch.avatar,
contentDescription = null,
modifier = Modifier.size(56.dp).clip(CircleShape),
)
}
Spacer(modifier = Modifier.height(4.dp))
// Single line + ellipsis instead of maxLines=2. The 80dp chip
// width breaks the prior 2-line wrap mid-word ("NoCopyrightS
// / ounds", "DEFCONConfe / rence") — uglier than a clean
// "NoCopyrigh…". Centered text alignment so the ellipsis
// sits over the chip's icon column.
Text(
text = ch.name,
style = MaterialTheme.typography.labelSmall,
maxLines = 2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RecentRow(item: WatchHistoryItem, onClick: () -> Unit) {
private fun RecentRow(
item: WatchHistoryItem,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = item.thumbnail,
contentDescription = null,
VideoThumbnail(
thumbnail = item.thumbnail,
videoUrl = item.url,
durationSeconds = 0L,
modifier = Modifier
.width(120.dp)
.height(68.dp)
.clip(RoundedCornerShape(6.dp)),
.height(68.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {

View file

@ -0,0 +1,90 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Straw palette pulled directly from sulkta.com's stylesheet:
* #4ade80 primary green (Tailwind green-400, most-used on the site)
* #166534 deep green (green-800, headings + emphasis)
* #22c55e mid green (green-500, links + buttons)
* #86efac light green container (green-300)
* #e8f5e8 pale green tint
* #d97706 amber accent (sulkta.com calls this out for chips)
* #374137 olive-gray secondary
* #0a0a0a near-black text on light
* #111411 near-black with green tint for dark surface
*
* Mapped into Material 3's primary / secondary / tertiary tonal roles
* so all the derived M3 surfaces (containers, outlines, etc.) follow.
*/
package com.sulkta.straw
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
// Light theme — primary is sulkta.com's deep green (#166534), strong
// enough for white text and matches the site's heading emphasis.
private val LPrimary = Color(0xFF166534)
private val LOnPrimary = Color(0xFFFFFFFF)
private val LPrimaryContainer = Color(0xFF86EFAC)
private val LOnPrimaryContainer = Color(0xFF0A0A0A)
private val LSecondary = Color(0xFF374137)
private val LOnSecondary = Color(0xFFFFFFFF)
private val LSecondaryContainer = Color(0xFFE8F5E8)
private val LOnSecondaryContainer = Color(0xFF0A0A0A)
private val LTertiary = Color(0xFFD97706)
private val LOnTertiary = Color(0xFFFFFFFF)
// Dark theme — primary is sulkta.com's bright lime (#4ade80) since dark
// backgrounds need a brighter accent for readability. PrimaryContainer
// is the deep green so emphasis stays consistent across themes.
private val DPrimary = Color(0xFF4ADE80)
private val DOnPrimary = Color(0xFF0A0A0A)
private val DPrimaryContainer = Color(0xFF166534)
private val DOnPrimaryContainer = Color(0xFF86EFAC)
private val DSecondary = Color(0xFF9AB89A)
private val DOnSecondary = Color(0xFF111411)
private val DSecondaryContainer = Color(0xFF374137)
private val DOnSecondaryContainer = Color(0xFFE8F5E8)
private val DTertiary = Color(0xFFD97706)
private val DOnTertiary = Color(0xFF0A0A0A)
fun strawLightColors(): ColorScheme = lightColorScheme(
primary = LPrimary,
onPrimary = LOnPrimary,
primaryContainer = LPrimaryContainer,
onPrimaryContainer = LOnPrimaryContainer,
secondary = LSecondary,
onSecondary = LOnSecondary,
secondaryContainer = LSecondaryContainer,
onSecondaryContainer = LOnSecondaryContainer,
tertiary = LTertiary,
onTertiary = LOnTertiary,
)
fun strawDarkColors(): ColorScheme = darkColorScheme(
primary = DPrimary,
onPrimary = DOnPrimary,
primaryContainer = DPrimaryContainer,
onPrimaryContainer = DOnPrimaryContainer,
secondary = DSecondary,
onSecondary = DOnSecondary,
secondaryContainer = DSecondaryContainer,
onSecondaryContainer = DOnSecondaryContainer,
tertiary = DTertiary,
onTertiary = DOnTertiary,
)
// Semi-transparent overlays for chrome (overlay buttons, the SB badge,
// the inline-player fullscreen pill) and for the dimmed area behind the
// minibar thumbnail. Kept here so a theme tweak touches one place.
val OverlayChromeColor = Color(0xCC222222)
val OverlayDimColor = Color(0xCC000000)
// Watch-progress bar painted across the bottom of a video thumbnail when
// the user has a saved scrub-point. Solid red foreground over a slightly-
// dim track. Matches YT / NewPipe conventions so it reads instantly.
val ProgressBarFillColor = Color(0xFFE53935)
val ProgressBarTrackColor = Color(0x66000000)

View file

@ -0,0 +1,136 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Subs-feed enrichment cache. RSS gives us title/url/thumbnail/date
* fast but no view count or duration. After a feed refresh paints
* from RSS, SubscriptionFeedViewModel fans out lightweight
* uniffi.strawcore.enrichFeedItem() calls for the top visible items
* and stashes the results here. mergeFromCache overlays the
* enrichment onto each StreamItem at render time so the row shows
* 'N views · X duration' once available.
*
* Storage: SharedPreferences-lite, single JSON blob keyed by videoId.
* TTL bound to Settings.cacheTtl so enrichments age out alongside the
* rest of the cache. Hard cap at MAX_ENRICHMENTS to bound disk +
* memory.
*/
package com.sulkta.straw.data
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class Enrichment(
val viewCount: Long,
val durationSeconds: Long,
val fetchedAt: Long,
)
private const val PREFS = "straw_feed_enrichment"
private const val KEY = "enrichments_v1"
/**
* Hard ceiling keeps the JSON blob below ~250 KB even at the cap
* (50 bytes/entry × 5000 = 250 KB). The user-facing cap doesn't tie
* to this; enrichment is "cache" not "user data."
*/
private const val MAX_ENRICHMENTS = 5_000
class EnrichmentStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true }
private val _entries = MutableStateFlow(load())
val entries: StateFlow<Map<String, Enrichment>> = _entries.asStateFlow()
/**
* Return a fresh enrichment for this videoId, or null when missing
* or aged out per Settings.cacheTtl. Forever-TTL never expires.
*/
fun get(videoId: String): Enrichment? {
if (videoId.isBlank()) return null
val e = _entries.value[videoId] ?: return null
val ttl = Settings.get().cacheTtl.value
if (ttl.isForever) return e
val cutoff = System.currentTimeMillis() - ttl.ms
return if (e.fetchedAt >= cutoff) e else null
}
fun put(videoId: String, viewCount: Long, durationSeconds: Long) {
if (videoId.isBlank()) return
// Don't write all-zero entries — that's failure not data, and
// would waste a slot the cap could spend on a real hit.
if (viewCount <= 0L && durationSeconds <= 0L) return
val entry = Enrichment(
viewCount = viewCount,
durationSeconds = durationSeconds,
fetchedAt = System.currentTimeMillis(),
)
val before = _entries.value
val next = _entries.updateAndGet { current ->
// short-circuit when the cached
// value is already the same view+duration — re-enriching
// within TTL otherwise allocates a new Map every call
// and the `before !== next` guard never triggers, so a
// refresh-after-refresh hammers the SP file.
val existing = current[videoId]
if (existing != null &&
existing.viewCount == entry.viewCount &&
existing.durationSeconds == entry.durationSeconds) {
return@updateAndGet current
}
val withEntry = current + (videoId to entry)
if (withEntry.size > MAX_ENRICHMENTS) {
withEntry.entries
.sortedByDescending { it.value.fetchedAt }
.take(MAX_ENRICHMENTS)
.associate { it.key to it.value }
} else {
withEntry
}
}
if (next !== before) {
sp.edit().putString(KEY, json.encodeToString(next)).apply()
}
}
fun clear() {
_entries.updateAndGet { emptyMap() }
sp.edit().putString(KEY, json.encodeToString(emptyMap<String, Enrichment>())).apply()
}
private fun load(): Map<String, Enrichment> = runCatching {
val s = sp.getString(KEY, null) ?: return emptyMap()
val loaded = json.decodeFromString<Map<String, Enrichment>>(s)
// prune TTL-expired entries on load
// so the store doesn't accumulate dead weight up to
// MAX_ENRICHMENTS over time. `Forever` TTL skips the prune.
val ttl = Settings.get().cacheTtl.value
if (ttl.isForever) return loaded
val cutoff = System.currentTimeMillis() - ttl.ms
loaded.filterValues { it.fetchedAt >= cutoff }
}.getOrDefault(emptyMap())
}
object FeedEnrichment {
@Volatile private var instance: EnrichmentStore? = null
fun init(context: Context) {
if (instance == null) {
synchronized(this) {
if (instance == null) instance = EnrichmentStore(context.applicationContext)
}
}
}
fun get(): EnrichmentStore = instance
?: error("EnrichmentStore not initialized — call FeedEnrichment.init(context)")
}

View file

@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Persistent per-channel cache for the subscription feed. Survives
* process death, so opening Subs after a cold start shows the last
* successful fetch immediately instead of waiting 5+ seconds for 30
* channel browses to resolve.
*
* Storage: SharedPreferences with a single JSON blob. Total payload is
* small (30 subs * 30 items * ~250 bytes = ~225 KB), well within SP's
* comfortable size and well below the multi-MB threshold where you'd
* want to graduate to Room or a file.
*
* Concurrency: writes from the feed VM are debounced via the single
* `persist` call inside fetchChannelInto's success path. Reads happen
* on VM init and are synchronous.
*/
package com.sulkta.straw.data
import android.content.Context
import android.content.SharedPreferences
import com.sulkta.straw.feature.search.StreamItem
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class FeedCacheEntry(
val fetchedAt: Long,
val items: List<StreamItem>,
)
private const val PREFS = "straw_feed_cache"
private const val KEY = "cache_v1"
class FeedCacheStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true }
/**
* Snapshot of the disk cache, filtered by the user-configured TTL.
* Returns empty map if nothing saved or everything expired.
* Settings.cacheTtl.isForever short-circuits the filter; finite TTLs
* drop entries whose fetchedAt is older than (now - ttl).
*/
fun load(): Map<String, FeedCacheEntry> = runCatching {
val s = sp.getString(KEY, null) ?: return emptyMap()
val raw = json.decodeFromString<Map<String, FeedCacheEntry>>(s)
val ttl = Settings.get().cacheTtl.value
if (ttl.isForever) return raw
val cutoff = System.currentTimeMillis() - ttl.ms
raw.filterValues { it.fetchedAt >= cutoff }
}.getOrDefault(emptyMap())
/** Atomic write. Caller is responsible for diffing if needed. */
fun save(map: Map<String, FeedCacheEntry>) {
val s = json.encodeToString(map)
sp.edit().putString(KEY, s).apply()
}
fun clear() {
sp.edit().remove(KEY).apply()
}
}
object FeedCache {
@Volatile private var appContext: Context? = null
@Volatile private var instance: FeedCacheStore? = null
/**
* Lazy init: stash the applicationContext only. The actual Store
* (and the ~225 KB JSON decode that happens at construction) is
* deferred until the first `get()` call. Lets Application.onCreate
* return quickly while every caller still gets a valid Store
* Callers should access from a coroutine
* (IO dispatcher) where the lazy construction cost is acceptable.
*/
fun init(context: Context) {
appContext = context.applicationContext
}
fun get(): FeedCacheStore {
instance?.let { return it }
synchronized(this) {
instance?.let { return it }
val ctx = appContext
?: error("FeedCacheStore not initialized — call FeedCache.init(context)")
val built = FeedCacheStore(ctx)
instance = built
return built
}
}
}

View file

@ -2,9 +2,9 @@
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* SharedPreferences-backed recent watches + recent search store. Day-3.
* Day-4 graduates to Room when there's a real query pattern (date ranges,
* full-text search, etc.) that SharedPreferences can't serve.
* Recent watches + recent searches backed by SharedPreferences JSON
* blobs. Capped to maxWatches() / maxSearches(). Graduates to Room when
* a real query pattern (date ranges, full-text search) shows up.
*/
package com.sulkta.straw.data
@ -31,12 +31,29 @@ data class WatchHistoryItem(
private const val PREFS = "straw_history"
private const val KEY_WATCHES = "watches_v1"
private const val KEY_SEARCHES = "searches_v1"
private const val MAX_WATCHES = 50
private const val MAX_SEARCHES = 20
/**
* Earlier hard limits. Still used as the absolute upper bound when
* Settings.historyWatchesCap is CacheCap.Unlimited we don't want to
* allow truly-uncapped growth that could OOM SP on a hostile import.
* Any user-picked cap above this is silently floored to MAX_*_HARD.
*/
private const val MAX_WATCHES_HARD = 100_000
private const val MAX_SEARCHES_HARD = 100_000
class HistoryStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val json = Json { ignoreUnknownKeys = true }
private fun maxWatches(): Int {
val cap = Settings.get().historyWatchesCap.value.value
return cap.coerceAtMost(MAX_WATCHES_HARD)
}
private fun maxSearches(): Int {
val cap = Settings.get().historySearchesCap.value.value
return cap.coerceAtMost(MAX_SEARCHES_HARD)
}
private val _watches = MutableStateFlow(loadWatches())
val watches: StateFlow<List<WatchHistoryItem>> = _watches.asStateFlow()
@ -46,34 +63,128 @@ class HistoryStore(context: Context) {
fun recordWatch(item: WatchHistoryItem) {
val now = item.copy(watchedAt = System.currentTimeMillis())
// Atomic read-modify-write via StateFlow.updateAndGet — fixes
// AUD-HIGH race where two concurrent recordWatch calls would
// each read the old list and one would clobber the other.
// Atomic read-modify-write — two concurrent recordWatch calls
// both reading the same `current` and one clobbering the other
// is exactly the bug updateAndGet avoids.
val next = _watches.updateAndGet { current ->
val without = current.filterNot { it.videoId == item.videoId }
(listOf(now) + without).take(MAX_WATCHES)
(listOf(now) + without).take(maxWatches())
}
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
}
/**
* Bulk import. Callers (currently SettingsImport) feed
* oldestnewest. Single SP write audit flagged the
* per-row recordWatch in importHistory as a write-storm vector.
*
* Walks input newest-first (input is fed oldest-first), filters
* blanks + already-seen videoIds, prepends to current, then takes
* maxWatches(). Imports WIN over older current entries when the
* store is at the cap the the first cut silently discarded
* the whole import in that case.
*
* Skips the SP write when the resulting list is identical (by
* reference equality after updateAndGet's no-op return) so a
* spam-import on an already-up-to-date store doesn't thrash disk.
*/
/**
* Returns the number of fresh items actually folded into the
* store on this call (counts new videoIds; duplicates of
* already-recorded entries don't count).
* SettingsImport previously reported `size_after - size_before`
* which lies when the store was at maxWatches() (post-state can
* be 50 = pre-state even when 20 imports landed and 20 older
* locals were truncated to make room).
*/
fun recordAllWatches(items: List<WatchHistoryItem>): Int {
if (items.isEmpty()) return 0
val before = _watches.value
val counter = java.util.concurrent.atomic.AtomicInteger(0)
val next = _watches.updateAndGet { current ->
// Reset the counter inside the CAS lambda so a retry
// doesn't accumulate across attempts — same shape as
// SubscriptionsStore.addAll's round-3 fix.
counter.set(0)
val seen = HashSet<String>(current.size + items.size)
current.forEach { seen.add(it.videoId) }
// Build the import list newest-first. Capped at
// maxWatches() on its own so we don't over-allocate
// even on a 50k-row hostile export.
val fresh = ArrayList<WatchHistoryItem>(maxWatches())
val it = items.listIterator(items.size)
while (it.hasPrevious() && fresh.size < maxWatches()) {
val item = it.previous()
if (item.videoId.isBlank()) continue
if (!seen.add(item.videoId)) continue
fresh.add(item)
counter.incrementAndGet()
}
if (fresh.isEmpty()) return@updateAndGet current
// Combine + cap. take() truncates older `current` entries
// when we'd exceed maxWatches(), so imports always land.
(fresh + current).take(maxWatches())
}
if (next !== before) {
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
}
return counter.get()
}
/**
* Bulk import for search history. Same pattern as
* recordAllWatches single SP write regardless of input size.
* SettingsImport.importHistory previously called recordSearch per
* row, producing N SP writes on a potentially-100k-row import.
*/
/**
* Returns the number of fresh queries actually folded into the
* store same counter pattern as recordAllWatches.
*/
fun recordAllSearches(queries: List<String>): Int {
if (queries.isEmpty()) return 0
val before = _searches.value
val counter = java.util.concurrent.atomic.AtomicInteger(0)
val next = _searches.updateAndGet { current ->
counter.set(0)
val seen = HashSet<String>(current.size + queries.size)
current.forEach { seen.add(it.lowercase()) }
val fresh = ArrayList<String>(maxSearches())
val it = queries.listIterator(queries.size)
while (it.hasPrevious() && fresh.size < maxSearches()) {
val q = it.previous().trim()
if (q.isEmpty()) continue
if (!seen.add(q.lowercase())) continue
fresh.add(q)
counter.incrementAndGet()
}
if (fresh.isEmpty()) return@updateAndGet current
(fresh + current).take(maxSearches())
}
if (next !== before) {
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
}
return counter.get()
}
fun recordSearch(query: String) {
val q = query.trim()
if (q.isEmpty()) return
val next = _searches.updateAndGet { current ->
val without = current.filterNot { it.equals(q, ignoreCase = true) }
(listOf(q) + without).take(MAX_SEARCHES)
(listOf(q) + without).take(maxSearches())
}
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
}
fun clearWatches() {
_watches.value = emptyList()
sp.edit().remove(KEY_WATCHES).apply()
_watches.updateAndGet { emptyList() }
sp.edit().putString(KEY_WATCHES, json.encodeToString(emptyList<WatchHistoryItem>())).apply()
}
fun clearSearches() {
_searches.value = emptyList()
sp.edit().remove(KEY_SEARCHES).apply()
_searches.updateAndGet { emptyList() }
sp.edit().putString(KEY_SEARCHES, json.encodeToString(emptyList<String>())).apply()
}
private fun loadWatches(): List<WatchHistoryItem> = runCatching {

View file

@ -0,0 +1,154 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* SharedPreferences-lite local playlists. User creates a playlist
* ("study music", "boss fight rage"), saves videos to it from
* VideoDetailScreen, and replays them later from the drawer. Same
* persistence pattern as SubscriptionsStore JSON blob in
* SharedPreferences, atomic updates via updateAndGet so concurrent
* "save to playlist" taps don't lose entries.
*
* No queue-autoplay yet tapping a video in a playlist navigates to
* VideoDetail like normal. Queue handoff would be its own task.
*/
package com.sulkta.straw.data
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.UUID
@Serializable
data class PlaylistItem(
val streamUrl: String,
val title: String,
val thumbnail: String? = null,
val uploader: String = "",
val addedAt: Long = 0L,
)
@Serializable
data class Playlist(
val id: String,
val name: String,
val createdAt: Long,
val items: List<PlaylistItem> = emptyList(),
)
private const val PREFS = "straw_playlists"
private const val KEY = "playlists_v1"
class PlaylistsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true }
private val _playlists = MutableStateFlow(load())
val playlists: StateFlow<List<Playlist>> = _playlists.asStateFlow()
fun create(name: String): Playlist {
val pl = Playlist(
id = UUID.randomUUID().toString(),
name = name.trim().ifBlank { "Untitled" },
createdAt = System.currentTimeMillis(),
)
val next = _playlists.updateAndGet { it + pl }
persist(next)
return pl
}
/**
* Bulk-import a playlist with all its items in a single CAS +
* single SP write. SettingsImport's old shape called create() +
* addItem() in a loop both write SP, and addItem walks every
* playlist linearly per insert. A 100-playlist × 100-items
* NewPipe export was ~10,001 SP commits + ~10M comparisons.
*/
fun importPlaylist(name: String, items: List<PlaylistItem>): Playlist {
val stampNow = System.currentTimeMillis()
// Dedup within the import + stamp addedAt once.
val seen = HashSet<String>()
val deduped = ArrayList<PlaylistItem>(items.size)
for (it in items) {
if (it.streamUrl.isBlank()) continue
if (!seen.add(it.streamUrl)) continue
deduped.add(it.copy(addedAt = if (it.addedAt == 0L) stampNow else it.addedAt))
}
val pl = Playlist(
id = UUID.randomUUID().toString(),
name = name.trim().ifBlank { "Untitled" },
createdAt = stampNow,
items = deduped,
)
val next = _playlists.updateAndGet { it + pl }
persist(next)
return pl
}
fun delete(id: String) {
val next = _playlists.updateAndGet { cur -> cur.filterNot { it.id == id } }
persist(next)
}
fun rename(id: String, newName: String) {
val trimmed = newName.trim().ifBlank { return }
val next = _playlists.updateAndGet { cur ->
cur.map { if (it.id == id) it.copy(name = trimmed) else it }
}
persist(next)
}
fun addItem(playlistId: String, item: PlaylistItem) {
val stamped = item.copy(addedAt = System.currentTimeMillis())
val next = _playlists.updateAndGet { cur ->
cur.map { pl ->
if (pl.id != playlistId) pl
else if (pl.items.any { it.streamUrl == stamped.streamUrl }) pl
else pl.copy(items = pl.items + stamped)
}
}
persist(next)
}
fun removeItem(playlistId: String, streamUrl: String) {
val next = _playlists.updateAndGet { cur ->
cur.map { pl ->
if (pl.id != playlistId) pl
else pl.copy(items = pl.items.filterNot { it.streamUrl == streamUrl })
}
}
persist(next)
}
fun get(id: String): Playlist? = _playlists.value.firstOrNull { it.id == id }
private fun persist(list: List<Playlist>) {
sp.edit().putString(KEY, json.encodeToString(list)).apply()
}
private fun load(): List<Playlist> = runCatching {
val s = sp.getString(KEY, null) ?: return emptyList()
json.decodeFromString<List<Playlist>>(s)
}.getOrDefault(emptyList())
}
object Playlists {
@Volatile private var instance: PlaylistsStore? = null
fun init(context: Context) {
if (instance == null) {
synchronized(this) {
if (instance == null) instance = PlaylistsStore(context.applicationContext)
}
}
}
fun get(): PlaylistsStore = instance
?: error("PlaylistsStore not initialized — call Playlists.init(context)")
}

View file

@ -0,0 +1,192 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Per-video scrub-point store. App update / process death / device
* reboot all three would otherwise lose the user's place in a long
* video. We write position every ~5s while playing + on every pause +
* on player teardown, keyed by videoId so resume works across stream
* URL rotations (googlevideo URLs rotate per session).
*
* SharedPreferences-lite, single JSON blob, capped at maxResumes() with
* oldest-eviction. Same shape as HistoryStore graduates to Room if a
* real query pattern shows up.
*/
package com.sulkta.straw.data
import android.content.Context
import android.content.SharedPreferences
import com.sulkta.straw.StrawApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class ResumePosition(
val positionMs: Long,
val durationMs: Long,
val lastWatchedAt: Long,
)
private const val PREFS = "straw_resume_positions"
private const val KEY_POSITIONS = "positions_v1"
/**
* Earlier hard cap. Now a ceiling rather than a fixed value: the
* user-picked cap from Settings.resumePositionsCap is silently floored
* to this so even "Unlimited" doesn't OOM SP. Bigger ceiling here
* than HistoryStore because resume entries are tiny (~50 bytes each)
* vs WatchHistoryItem's ~250 bytes.
*/
private const val MAX_RESUMES_HARD = 100_000
/**
* Skip writes for trivial positions auto-resuming from 0:03 is more
* annoying than starting fresh. Mirrors YouTube's "near the start"
* threshold.
*/
private const val MIN_POSITION_MS = 5_000L
/**
* When position is within END_THRESHOLD of duration, treat the video as
* "done" and clear the entry instead of recording. Otherwise a finished
* watch would auto-resume to the credits next time.
*/
private const val END_THRESHOLD_MS = 5_000L
class ResumePositionsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true }
private val _positions = MutableStateFlow(load())
val positions: StateFlow<Map<String, ResumePosition>> = _positions.asStateFlow()
private fun maxResumes(): Int {
val cap = Settings.get().resumePositionsCap.value.value
return cap.coerceAtMost(MAX_RESUMES_HARD)
}
/**
* Record (or update) the scrub-point for a video. Skipped silently
* when:
* - videoId is blank
* - durationMs <= 0 (live stream / unknown)
* - positionMs is below MIN_POSITION_MS (just started)
*
* When positionMs is within END_THRESHOLD_MS of the end the entry is
* REMOVED so a finished video doesn't auto-resume to its credits.
*/
fun record(videoId: String, positionMs: Long, durationMs: Long) {
if (videoId.isBlank()) return
if (durationMs <= 0L) return
if (positionMs < MIN_POSITION_MS) return
if (positionMs >= durationMs - END_THRESHOLD_MS) {
clearOne(videoId)
return
}
val entry = ResumePosition(
positionMs = positionMs,
durationMs = durationMs,
lastWatchedAt = System.currentTimeMillis(),
)
val before = _positions.value
val next = _positions.updateAndGet { current ->
// short-circuit value-equality
// a 5s poll tick that finds the same (position, duration,
// wall-time) for an existing entry returns `current`
// unchanged so the outer `next !== before` guard
// actually short-circuits the SP write.
//
// lastWatchedAt updates every tick by definition, but
// ResumePosition equality on position+duration alone is
// ALL we care about for "did anything meaningful change."
// We re-stamp lastWatchedAt only when the player position
// actually advances.
val existing = current[videoId]
if (existing != null &&
existing.positionMs == entry.positionMs &&
existing.durationMs == entry.durationMs) {
return@updateAndGet current
}
val withEntry = current + (videoId to entry)
// Skip sort+associate when we're under the cap (the
// common case at default 500). Sort is O(n log n);
// associate allocates another map.
if (withEntry.size > maxResumes()) {
// Drop oldest by lastWatchedAt — newcomers always land
// because the entry we just added is by definition the
// freshest. take(maxResumes()) of the sorted-desc list.
withEntry.entries
.sortedByDescending { it.value.lastWatchedAt }
.take(maxResumes())
.associate { it.key to it.value }
} else {
withEntry
}
}
if (next !== before) {
// JSON encode + SP write off Main — encoding 100k entries
// would be ~50-100 ms on a low-end device, and the 5s
// captureResumePosition poll runs on Main.
StrawApp.globalScope.launch(Dispatchers.IO) {
sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply()
}
}
}
/** Returns null when the video has no recorded position. */
fun get(videoId: String): ResumePosition? {
if (videoId.isBlank()) return null
return _positions.value[videoId]
}
fun clearOne(videoId: String) {
if (videoId.isBlank()) return
val before = _positions.value
val next = _positions.updateAndGet { current ->
if (videoId !in current) current else current - videoId
}
if (next !== before) {
StrawApp.globalScope.launch(Dispatchers.IO) {
sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply()
}
}
}
fun clearAll() {
val before = _positions.value
_positions.updateAndGet { emptyMap() }
if (before.isNotEmpty()) {
StrawApp.globalScope.launch(Dispatchers.IO) {
sp.edit().putString(KEY_POSITIONS, json.encodeToString(emptyMap<String, ResumePosition>())).apply()
}
}
}
private fun load(): Map<String, ResumePosition> = runCatching {
val s = sp.getString(KEY_POSITIONS, null) ?: return emptyMap()
json.decodeFromString<Map<String, ResumePosition>>(s)
}.getOrDefault(emptyMap())
}
/** App-wide singleton; created in StrawApp.onCreate. */
object Resume {
@Volatile private var instance: ResumePositionsStore? = null
fun init(context: Context) {
if (instance == null) {
synchronized(this) {
if (instance == null) instance = ResumePositionsStore(context.applicationContext)
}
}
}
fun get(): ResumePositionsStore = instance
?: error("ResumePositionsStore not initialized — call Resume.init(context)")
}

View file

@ -0,0 +1,123 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Search-result cache. Holds the last N executed queries and their
* result lists so:
* - Re-running a recent query paints from cache in one frame.
* - Reactive-as-you-type filtering can scan all cached items as
* the user types, surfacing matches before they hit Search.
*
* Sized for SharedPreferences: 30 queries * 20 items each * ~250 bytes
* = ~150 KB worst case.
*
* Backed by a MutableStateFlow loaded once at construction
* record/load are atomic against concurrent calls. audit
* B5: the prior load()edit()write() pattern would clobber a
* concurrent record() with whichever happened to persist last.
*
* Skips entirely when Settings.cacheEnabled is false caller checks
* the flag before reading/writing.
*/
package com.sulkta.straw.data
import android.content.Context
import android.content.SharedPreferences
import com.sulkta.straw.feature.search.StreamItem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class SearchCacheEntry(
val query: String,
val fetchedAt: Long,
val items: List<StreamItem>,
)
private const val PREFS = "straw_search_cache"
private const val KEY = "search_v1"
private const val MAX_QUERIES_HARD = 5000
private const val MAX_ITEMS_PER_QUERY = 20
class SearchCacheStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true }
private val _entries = MutableStateFlow(loadFromDisk())
val entries: StateFlow<List<SearchCacheEntry>> = _entries.asStateFlow()
private fun maxQueries(): Int =
Settings.get().searchCacheCap.value.value.coerceAtMost(MAX_QUERIES_HARD)
/**
* Filter out entries older than the configured TTL. Called on every
* read path so stale data never surfaces. Forever (ttl.isForever)
* is a no-op. Returns a fresh list caller decides whether to
* persist the trim.
*/
private fun filterByTtl(items: List<SearchCacheEntry>): List<SearchCacheEntry> {
val ttl = Settings.get().cacheTtl.value
if (ttl.isForever) return items
val cutoff = System.currentTimeMillis() - ttl.ms
return items.filter { it.fetchedAt >= cutoff }
}
/** Snapshot of the cache. Used by the reactive search filter. */
fun load(): List<SearchCacheEntry> = filterByTtl(_entries.value)
/**
* Record a freshly-fetched query result. Idempotent: a re-run of
* the same query overwrites the prior entry rather than duplicating.
* Oldest entries fall off when maxQueries() is exceeded.
*
* Atomic via updateAndGet concurrent records don't lose entries.
*/
fun record(query: String, items: List<StreamItem>) {
val q = query.trim()
if (q.isEmpty() || items.isEmpty()) return
val capped = items.take(MAX_ITEMS_PER_QUERY)
val now = System.currentTimeMillis()
val next = _entries.updateAndGet { current ->
val without = current.filterNot { it.query.equals(q, ignoreCase = true) }
(listOf(SearchCacheEntry(q, now, capped)) + without).take(maxQueries())
}
sp.edit().putString(KEY, json.encodeToString(next)).apply()
}
fun clear() {
_entries.value = emptyList()
sp.edit().remove(KEY).apply()
}
private fun loadFromDisk(): List<SearchCacheEntry> = runCatching {
val s = sp.getString(KEY, null) ?: return emptyList()
json.decodeFromString<List<SearchCacheEntry>>(s)
}.getOrDefault(emptyList())
}
object SearchCache {
@Volatile private var appContext: Context? = null
@Volatile private var instance: SearchCacheStore? = null
/** Lazy init — see FeedCache.init for the rationale. */
fun init(context: Context) {
appContext = context.applicationContext
}
fun get(): SearchCacheStore {
instance?.let { return it }
synchronized(this) {
instance?.let { return it }
val ctx = appContext
?: error("SearchCacheStore not initialized — call SearchCache.init(context)")
val built = SearchCacheStore(ctx)
instance = built
return built
}
}
}

View file

@ -37,9 +37,108 @@ enum class MaxResolution(val label: String, val ceiling: Int) {
P144("144p", 144),
}
enum class ThemeMode(val label: String) {
System("Follow system"),
Light("Light"),
Dark("Dark"),
}
/**
* When a video ends with nothing left in the queue, what should the
* player do? `Off` stops at the end (matches NewPipe's default).
* `SameChannel` chains to the next video from the same uploader
* fits Straw's user-curated ethos (you opted into this channel).
* `YtRelated` pulls from `info.related` (YouTube's algorithmic
* suggestion); deferred until strawcore populates `related` from
* the /next response for now it's identical to `Off`.
*/
enum class AutoplayMode(val label: String, val help: String) {
Off("Off", "Stop at the end."),
SameChannel("Same channel", "Play the next video from the same uploader."),
YtRelated("YouTube related", "Pull from YT's related suggestions. (not yet wired — extractor returns empty)"),
}
/**
* How often the auto-update worker polls fdroid.sulkta.com. WorkManager
* has a 15-minute floor on periodic work, so 1h is the tightest cadence
* we expose.
*/
enum class AutoUpdateInterval(val label: String) {
H1("Every hour"),
H6("Every 6 hours"),
H24("Every 24 hours"),
}
/**
* User-facing cache caps. Each store's hard limit is the cap's value;
* `Int.MAX_VALUE` means "unlimited" (the store grows without trimming).
* Defaults match the earlier hardcoded constants so existing data
* keeps the same shape until the user picks something different.
*/
enum class CacheCap(val label: String, val value: Int) {
Tiny("50", 50),
Small("200", 200),
Medium("1000", 1000),
Large("10000", 10000),
Unlimited("Unlimited", Int.MAX_VALUE);
companion object {
fun nearest(target: Int): CacheCap =
entries.firstOrNull { it.value == target } ?: Unlimited
}
}
/**
* TTL knob for time-decayed caches (subs feed + search results). 0
* means "forever" entries never time out and only fall off via
* size cap. Shorter TTLs reclaim disk on devices with tight storage.
*/
enum class CacheTtl(val label: String, val days: Int) {
D1("1 day", 1),
D7("7 days", 7),
D30("30 days", 30),
D365("1 year", 365),
Forever("Forever", 0);
val isForever: Boolean get() = days == 0
val ms: Long get() = days.toLong() * 24L * 60L * 60L * 1000L
}
/**
* How often the background subs-feed-refresh worker polls. Defaults to
* 1h tighter than that wastes battery without meaningful freshness
* gain (YouTube uploads aren't real-time). Background worker is OFF
* by default; opt-in via Settings.
*/
enum class BgFeedRefreshInterval(val label: String) {
M30("Every 30 minutes"),
H1("Every hour"),
H6("Every 6 hours"),
}
private const val PREFS = "straw_settings"
private const val KEY_SB_CATS = "sb_categories_v1"
private const val KEY_MAX_RES = "max_resolution_v1"
private const val KEY_THEME = "theme_mode_v1"
private const val KEY_CACHE_ENABLED = "cache_enabled_v1"
private const val KEY_AUTOPLAY_MODE = "autoplay_mode_v1"
private const val KEY_AUTOPLAY_SKIP_WATCHED = "autoplay_skip_watched_v1"
private const val KEY_AUTOSTART_PLAYBACK = "autostart_playback_v1"
private const val KEY_PAUSE_ON_HEADPHONE_DISCONNECT = "pause_on_headphone_disconnect_v1"
private const val KEY_AUTO_RESUME = "auto_resume_v1"
private const val KEY_AUTO_UPDATE_CHECK = "auto_update_check_v1"
private const val KEY_AUTO_UPDATE_INTERVAL = "auto_update_interval_v1"
private const val KEY_LAST_UPDATE_CHECK_MS = "last_update_check_ms_v1"
private const val KEY_LATEST_KNOWN_VC = "latest_known_vc_v1"
private const val KEY_LATEST_KNOWN_VNAME = "latest_known_vname_v1"
private const val KEY_HIDE_SHORTS = "hide_shorts_v1"
private const val KEY_CACHE_HISTORY_WATCHES = "cache_history_watches_v1"
private const val KEY_CACHE_HISTORY_SEARCHES = "cache_history_searches_v1"
private const val KEY_CACHE_RESUME_POSITIONS = "cache_resume_positions_v1"
private const val KEY_CACHE_SEARCH = "cache_search_v1"
private const val KEY_CACHE_TTL = "cache_ttl_v1"
private const val KEY_BG_FEED_REFRESH_ENABLED = "bg_feed_refresh_enabled_v1"
private const val KEY_BG_FEED_REFRESH_INTERVAL = "bg_feed_refresh_interval_v1"
class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@ -50,6 +149,156 @@ class SettingsStore(context: Context) {
private val _maxResolution = MutableStateFlow(loadMaxResolution())
val maxResolution: StateFlow<MaxResolution> = _maxResolution.asStateFlow()
private val _themeMode = MutableStateFlow(loadThemeMode())
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
private val _cacheEnabled = MutableStateFlow(sp.getBoolean(KEY_CACHE_ENABLED, true))
val cacheEnabled: StateFlow<Boolean> = _cacheEnabled.asStateFlow()
private val _autoplayMode = MutableStateFlow(loadAutoplayMode())
val autoplayMode: StateFlow<AutoplayMode> = _autoplayMode.asStateFlow()
private val _autoplaySkipWatched = MutableStateFlow(
sp.getBoolean(KEY_AUTOPLAY_SKIP_WATCHED, true),
)
val autoplaySkipWatched: StateFlow<Boolean> = _autoplaySkipWatched.asStateFlow()
/**
* "Open a video → it starts playing immediately." Default on
* matches YT/NewPipe. When off, opening a fresh video lands you
* on the detail page with the thumbnail + Play overlay; you tap
* to start. Doesn't affect back-from-fullscreen (that's a
* separate path in VideoDetailScreen that defaults to true when
* the shared controller is already streaming the URL).
*/
private val _autoStartPlayback = MutableStateFlow(
sp.getBoolean(KEY_AUTOSTART_PLAYBACK, true),
)
val autoStartPlayback: StateFlow<Boolean> = _autoStartPlayback.asStateFlow()
/**
* Honor Android's AUDIO_BECOMING_NOISY broadcast wired headphones
* yanked / Bluetooth disconnect pause instead of switching to the
* phone speaker. Default on; matches every other Android media app.
* Off lets playback follow the audio focus default (phone speaker
* takes over).
*/
private val _pauseOnHeadphoneDisconnect = MutableStateFlow(
sp.getBoolean(KEY_PAUSE_ON_HEADPHONE_DISCONNECT, true),
)
val pauseOnHeadphoneDisconnect: StateFlow<Boolean> = _pauseOnHeadphoneDisconnect.asStateFlow()
/**
* Auto-resume scrub-point on video open. When on (default), opening
* a video that has a saved position picks up where the user left
* off. When off, every open starts at 0:00. Doesn't affect inline-
* fullscreen hand-off (the shared MediaController keeps its own
* position across surfaces; this only matters on fresh opens).
*/
private val _autoResume = MutableStateFlow(
sp.getBoolean(KEY_AUTO_RESUME, true),
)
val autoResume: StateFlow<Boolean> = _autoResume.asStateFlow()
/**
* Periodic self-update check against fdroid.sulkta.com. Default on
* NewPipe's "user forgets to update for 6 months" failure mode
* is the explicit thing we're closing.
*/
private val _autoUpdateCheck = MutableStateFlow(
sp.getBoolean(KEY_AUTO_UPDATE_CHECK, true),
)
val autoUpdateCheck: StateFlow<Boolean> = _autoUpdateCheck.asStateFlow()
private val _autoUpdateInterval = MutableStateFlow(loadAutoUpdateInterval())
val autoUpdateInterval: StateFlow<AutoUpdateInterval> = _autoUpdateInterval.asStateFlow()
/** Last successful poll wall-clock ms; 0 if never. */
private val _lastUpdateCheckMs = MutableStateFlow(
sp.getLong(KEY_LAST_UPDATE_CHECK_MS, 0L),
)
val lastUpdateCheckMs: StateFlow<Long> = _lastUpdateCheckMs.asStateFlow()
/**
* Cached "latest version seen on fdroid" 0 / "" while none known
* or while caught-up. Lets SettingsScreen show "an update available"
* without re-polling.
*/
private val _latestKnownVc = MutableStateFlow(
sp.getLong(KEY_LATEST_KNOWN_VC, 0L),
)
val latestKnownVc: StateFlow<Long> = _latestKnownVc.asStateFlow()
private val _latestKnownVname = MutableStateFlow(
sp.getString(KEY_LATEST_KNOWN_VNAME, "") ?: "",
)
val latestKnownVname: StateFlow<String> = _latestKnownVname.asStateFlow()
/**
* Hide YouTube Shorts everywhere. Detection is multi-signal because
* each surface gives different hints:
* - Search + ChannelScreen results: URL pattern `/shorts/<id>` is
* reliable (strawcore preserves it).
* - Subscription RSS feed: URLs come back as canonical `watch?v=`
* so URL alone won't trip; fall back to title containing
* "#shorts" / "#Shorts" / "(shorts)" which most short uploaders
* include.
* Filter is best-effort a hand-tagged short with a clean title
* in the subs feed will slip through until a future build plumbs an
* isShort flag through strawcore-core.
*/
private val _hideShorts = MutableStateFlow(
sp.getBoolean(KEY_HIDE_SHORTS, false),
)
val hideShorts: StateFlow<Boolean> = _hideShorts.asStateFlow()
/**
* Per-store cache caps. Each store reads its cap from the matching
* StateFlow on every prune cycle so flipping the toggle in Settings
* takes effect immediately (next write trims to the new cap; reads
* are unbounded since they're already in memory).
*
* Defaults match the earlier hardcoded constants so first-launch
* behavior is unchanged from prior versions.
*/
private val _historyWatchesCap = MutableStateFlow(
CacheCap.nearest(sp.getInt(KEY_CACHE_HISTORY_WATCHES, 50)),
)
val historyWatchesCap: StateFlow<CacheCap> = _historyWatchesCap.asStateFlow()
private val _historySearchesCap = MutableStateFlow(
loadCap(KEY_CACHE_HISTORY_SEARCHES, default = 20),
)
val historySearchesCap: StateFlow<CacheCap> = _historySearchesCap.asStateFlow()
private val _resumePositionsCap = MutableStateFlow(
loadCap(KEY_CACHE_RESUME_POSITIONS, default = 500),
)
val resumePositionsCap: StateFlow<CacheCap> = _resumePositionsCap.asStateFlow()
private val _searchCacheCap = MutableStateFlow(
loadCap(KEY_CACHE_SEARCH, default = 30),
)
val searchCacheCap: StateFlow<CacheCap> = _searchCacheCap.asStateFlow()
private val _cacheTtl = MutableStateFlow(loadCacheTtl())
val cacheTtl: StateFlow<CacheTtl> = _cacheTtl.asStateFlow()
/**
* Background subscription-feed refresh WorkManager periodic job
* that pre-warms FeedCache so the next cold open paints a fresh
* feed without pull-to-refresh. Off by default; cell-network
* battery cost is the explicit opt-in.
*/
private val _bgFeedRefreshEnabled = MutableStateFlow(
sp.getBoolean(KEY_BG_FEED_REFRESH_ENABLED, false),
)
val bgFeedRefreshEnabled: StateFlow<Boolean> = _bgFeedRefreshEnabled.asStateFlow()
private val _bgFeedRefreshInterval = MutableStateFlow(loadBgFeedInterval())
val bgFeedRefreshInterval: StateFlow<BgFeedRefreshInterval> =
_bgFeedRefreshInterval.asStateFlow()
fun toggle(cat: SbCategory) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
val next = _sbCategories.updateAndGet { cur ->
@ -58,11 +307,158 @@ class SettingsStore(context: Context) {
sp.edit().putStringSet(KEY_SB_CATS, next.map { it.key }.toSet()).apply()
}
// Atomic + idempotent. Capture before-state, update in-memory,
// skip the SP write when the value didn't actually change. The
// prior shape used `updateAndGet { r } == r` which is unconditionally
// true (the lambda ignores prior) — dead code that confused readers.
fun setMaxResolution(r: MaxResolution) {
val before = _maxResolution.value
if (before == r) return
_maxResolution.value = r
sp.edit().putString(KEY_MAX_RES, r.name).apply()
}
fun setThemeMode(t: ThemeMode) {
val before = _themeMode.value
if (before == t) return
_themeMode.value = t
sp.edit().putString(KEY_THEME, t.name).apply()
}
fun setCacheEnabled(enabled: Boolean) {
val before = _cacheEnabled.value
if (before == enabled) return
_cacheEnabled.value = enabled
sp.edit().putBoolean(KEY_CACHE_ENABLED, enabled).apply()
}
fun setAutoplayMode(mode: AutoplayMode) {
val before = _autoplayMode.value
if (before == mode) return
_autoplayMode.value = mode
sp.edit().putString(KEY_AUTOPLAY_MODE, mode.name).apply()
}
fun setAutoplaySkipWatched(skip: Boolean) {
val before = _autoplaySkipWatched.value
if (before == skip) return
_autoplaySkipWatched.value = skip
sp.edit().putBoolean(KEY_AUTOPLAY_SKIP_WATCHED, skip).apply()
}
fun setAutoStartPlayback(autoStart: Boolean) {
val before = _autoStartPlayback.value
if (before == autoStart) return
_autoStartPlayback.value = autoStart
sp.edit().putBoolean(KEY_AUTOSTART_PLAYBACK, autoStart).apply()
}
fun setPauseOnHeadphoneDisconnect(pause: Boolean) {
val before = _pauseOnHeadphoneDisconnect.value
if (before == pause) return
_pauseOnHeadphoneDisconnect.value = pause
sp.edit().putBoolean(KEY_PAUSE_ON_HEADPHONE_DISCONNECT, pause).apply()
}
fun setAutoResume(enabled: Boolean) {
val before = _autoResume.value
if (before == enabled) return
_autoResume.value = enabled
sp.edit().putBoolean(KEY_AUTO_RESUME, enabled).apply()
}
fun setAutoUpdateCheck(enabled: Boolean) {
val before = _autoUpdateCheck.value
if (before == enabled) return
_autoUpdateCheck.value = enabled
sp.edit().putBoolean(KEY_AUTO_UPDATE_CHECK, enabled).apply()
}
fun setAutoUpdateInterval(interval: AutoUpdateInterval) {
val before = _autoUpdateInterval.value
if (before == interval) return
_autoUpdateInterval.value = interval
sp.edit().putString(KEY_AUTO_UPDATE_INTERVAL, interval.name).apply()
}
fun setLastUpdateCheck(ms: Long) {
_lastUpdateCheckMs.value = ms
sp.edit().putLong(KEY_LAST_UPDATE_CHECK_MS, ms).apply()
}
fun setLatestKnownVersion(vc: Long, vname: String) {
_latestKnownVc.value = vc
_latestKnownVname.value = vname
sp.edit()
.putLong(KEY_LATEST_KNOWN_VC, vc)
.putString(KEY_LATEST_KNOWN_VNAME, vname)
.apply()
}
fun setHideShorts(hide: Boolean) {
val before = _hideShorts.value
if (before == hide) return
_hideShorts.value = hide
sp.edit().putBoolean(KEY_HIDE_SHORTS, hide).apply()
}
fun setHistoryWatchesCap(cap: CacheCap) {
if (_historyWatchesCap.value == cap) return
_historyWatchesCap.value = cap
sp.edit().putInt(KEY_CACHE_HISTORY_WATCHES, cap.value).apply()
}
fun setHistorySearchesCap(cap: CacheCap) {
if (_historySearchesCap.value == cap) return
_historySearchesCap.value = cap
sp.edit().putInt(KEY_CACHE_HISTORY_SEARCHES, cap.value).apply()
}
fun setResumePositionsCap(cap: CacheCap) {
if (_resumePositionsCap.value == cap) return
_resumePositionsCap.value = cap
sp.edit().putInt(KEY_CACHE_RESUME_POSITIONS, cap.value).apply()
}
fun setSearchCacheCap(cap: CacheCap) {
if (_searchCacheCap.value == cap) return
_searchCacheCap.value = cap
sp.edit().putInt(KEY_CACHE_SEARCH, cap.value).apply()
}
fun setCacheTtl(ttl: CacheTtl) {
if (_cacheTtl.value == ttl) return
_cacheTtl.value = ttl
sp.edit().putString(KEY_CACHE_TTL, ttl.name).apply()
}
fun setBgFeedRefreshEnabled(enabled: Boolean) {
if (_bgFeedRefreshEnabled.value == enabled) return
_bgFeedRefreshEnabled.value = enabled
sp.edit().putBoolean(KEY_BG_FEED_REFRESH_ENABLED, enabled).apply()
}
fun setBgFeedRefreshInterval(interval: BgFeedRefreshInterval) {
if (_bgFeedRefreshInterval.value == interval) return
_bgFeedRefreshInterval.value = interval
sp.edit().putString(KEY_BG_FEED_REFRESH_INTERVAL, interval.name).apply()
}
private fun loadCap(key: String, default: Int): CacheCap =
CacheCap.nearest(sp.getInt(key, default))
private fun loadCacheTtl(): CacheTtl {
val name = sp.getString(KEY_CACHE_TTL, null) ?: return CacheTtl.D30
return CacheTtl.entries.firstOrNull { it.name == name } ?: CacheTtl.D30
}
private fun loadBgFeedInterval(): BgFeedRefreshInterval {
val name = sp.getString(KEY_BG_FEED_REFRESH_INTERVAL, null)
?: return BgFeedRefreshInterval.H1
return BgFeedRefreshInterval.entries.firstOrNull { it.name == name }
?: BgFeedRefreshInterval.H1
}
private fun loadCategories(): Set<SbCategory> {
val raw = sp.getStringSet(KEY_SB_CATS, null)
return if (raw == null) {
@ -77,6 +473,26 @@ class SettingsStore(context: Context) {
val name = sp.getString(KEY_MAX_RES, null) ?: return MaxResolution.Auto
return MaxResolution.entries.firstOrNull { it.name == name } ?: MaxResolution.Auto
}
private fun loadThemeMode(): ThemeMode {
val name = sp.getString(KEY_THEME, null) ?: return ThemeMode.System
return ThemeMode.entries.firstOrNull { it.name == name } ?: ThemeMode.System
}
private fun loadAutoplayMode(): AutoplayMode {
// Default to SameChannel — user explicitly chose "on by default,
// plays next account's video" 2026-05-26. Off-by-default doesn't
// fit the workflow (queue empties → silence).
val name = sp.getString(KEY_AUTOPLAY_MODE, null) ?: return AutoplayMode.SameChannel
return AutoplayMode.entries.firstOrNull { it.name == name } ?: AutoplayMode.SameChannel
}
private fun loadAutoUpdateInterval(): AutoUpdateInterval {
val name = sp.getString(KEY_AUTO_UPDATE_INTERVAL, null)
?: return AutoUpdateInterval.H6
return AutoUpdateInterval.entries.firstOrNull { it.name == name }
?: AutoUpdateInterval.H6
}
}
object Settings {

View file

@ -2,8 +2,8 @@
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* SharedPreferences-lite subscription list. Day-4 graduates to Room when
* we want background feed fetching for new uploads.
* Subscription list backed by a single JSON blob in SharedPreferences.
* Graduates to Room when background feed fetching arrives.
*/
package com.sulkta.straw.data
@ -29,7 +29,7 @@ private const val KEY = "subs_v1"
class SubscriptionsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val json = Json { ignoreUnknownKeys = true }
private val _subs = MutableStateFlow(load())
val subs: StateFlow<List<ChannelRef>> = _subs.asStateFlow()
@ -38,7 +38,9 @@ class SubscriptionsStore(context: Context) {
_subs.value.any { it.url == channelUrl }
fun toggle(ref: ChannelRef) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
// updateAndGet makes the read-modify-write atomic vs. concurrent
// toggles (e.g. one channel subscribed from the feed while another
// is unsubscribed from VideoDetail).
val next = _subs.updateAndGet { cur ->
if (cur.any { it.url == ref.url }) {
cur.filterNot { it.url == ref.url }
@ -49,9 +51,57 @@ class SubscriptionsStore(context: Context) {
persist(next)
}
/**
* Update the cached avatar for an already-subscribed channel. Used
* by the subs feed fetch when it pulls a fresh ChannelInfo and the
* stored ChannelRef has a null avatar (channel header parser missed
* it at subscribe time). No-op for non-subscribed URLs.
*/
fun updateAvatar(channelUrl: String, avatar: String) {
val next = _subs.updateAndGet { cur ->
cur.map { if (it.url == channelUrl) it.copy(avatar = avatar) else it }
}
persist(next)
}
/**
* Bulk-add. Single persist instead of N. Per-call `toggle()` was
* O() + N SP writes, which the security audit flagged as
* a DoS vector for hostile NewPipe-export imports. Single linear
* scan to dedup, one persist regardless of input size. Returns the
* count of NEW (not previously-subscribed) channels added so the
* caller can report an "added X" stat.
*/
fun addAll(refs: List<ChannelRef>): Int {
// Count NEW refs by checking each input URL against the
// current state's pre-image inside the CAS lambda. Captures
// exactly the additions this call made — concurrent
// toggles that race the CAS don't inflate the count (
// ). The counter lives in an
// AtomicInteger so each lambda re-run resets it correctly.
val counter = java.util.concurrent.atomic.AtomicInteger(0)
val next = _subs.updateAndGet { state ->
counter.set(0)
val byUrl = state.associateBy { it.url }.toMutableMap()
for (r in refs) {
if (r.url.isBlank()) continue
if (r.url !in byUrl) {
byUrl[r.url] = r
counter.incrementAndGet()
}
}
byUrl.values.toList()
}
persist(next)
return counter.get()
}
fun clear() {
_subs.value = emptyList()
sp.edit().remove(KEY).apply()
// Same atomic-update path as toggle — protects against a concurrent
// toggle racing the clear and persisting [new-item] after the
// remove() call has fired.
_subs.updateAndGet { emptyList() }
persist(emptyList())
}
private fun persist(list: List<ChannelRef>) {

View file

@ -1,96 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Minimal OkHttp-backed implementation of NewPipeExtractor's Downloader.
* No cookies, no recaptcha handling anonymous browsing only. Modeled after
* NewPipe's DownloaderImpl but trimmed down for fork scope.
*/
package com.sulkta.straw.extractor
import com.sulkta.straw.net.NEWPIPE_MAX_BYTES
import com.sulkta.straw.net.cappedString
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
class NewPipeDownloader private constructor(
private val client: OkHttpClient,
) : Downloader() {
override fun execute(request: Request): Response {
val httpMethod = request.httpMethod()
val url = request.url()
val headers = request.headers()
val data: ByteArray? = request.dataToSend()
val requestBody = data?.toRequestBody(null)
val okBuilder = okhttp3.Request.Builder()
.method(httpMethod, requestBody)
.url(url)
// AUD-HIGH: copy NPE headers BEFORE adding our explicit UA so the
// explicit UA wins; guard against header values containing \r/\n
// which OkHttp's addHeader rejects via IAE (turning a poisoned
// response into an app crash).
headers.forEach { (name, values) ->
if (name.equals("User-Agent", ignoreCase = true)) return@forEach
okBuilder.removeHeader(name)
values.forEach { value ->
runCatching { okBuilder.addHeader(name, value) }
}
}
okBuilder.removeHeader("User-Agent")
okBuilder.addHeader("User-Agent", USER_AGENT)
val okResponse = client.newCall(okBuilder.build()).execute()
val body = okResponse.body
// AUD-HIGH: bounded read to defend against OOM via gigabyte response.
val bodyString = body?.cappedString(NEWPIPE_MAX_BYTES) ?: ""
val responseHeaders = okResponse.headers.toMultimap()
val latestUrl = okResponse.request.url.toString()
if (okResponse.code == 429) {
okResponse.close()
throw IOException("HTTP 429 — rate limited")
}
okResponse.close()
return Response(
okResponse.code,
okResponse.message,
responseHeaders,
bodyString,
latestUrl,
)
}
companion object {
const val USER_AGENT =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/120.0.0.0 Mobile Safari/537.36"
@Volatile private var instance: NewPipeDownloader? = null
fun init(builder: OkHttpClient.Builder? = null): NewPipeDownloader {
val client = (builder ?: OkHttpClient.Builder())
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
val d = NewPipeDownloader(client)
instance = d
return d
}
fun get(): NewPipeDownloader = instance
?: error("NewPipeDownloader not initialized — call init() first")
fun client(): OkHttpClient = get().client
}
}

View file

@ -5,7 +5,9 @@
package com.sulkta.straw.feature.channel
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -41,13 +43,20 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.sulkta.straw.feature.player.VideoThumbnail
import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.playlist.VideoActionTarget
import com.sulkta.straw.feature.playlist.VideoActionsSheet
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.formatCount
import com.sulkta.straw.util.rememberBottomContentPadding
import com.sulkta.straw.util.formatDuration
@Composable
@ -61,8 +70,22 @@ fun ChannelScreen(
LaunchedEffect(channelUrl) { vm.load(channelUrl) }
val subs by Subscriptions.get().subs.collectAsState()
val subscribed = subs.any { it.url == channelUrl }
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
actionTarget?.let { t ->
VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
}
when {
// Stale-state gate: activity-scoped VM, so when we navigate A → B
// the screen recomposes once with A's state before vm.load(B)
// resets it. Without this branch we'd render channel A's banner /
// name / videos under URL B. Same shape as VideoDetailScreen's
// gate.
state.loadedUrl != channelUrl -> Box(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
state.loading -> Box(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentAlignment = Alignment.Center,
@ -75,7 +98,18 @@ fun ChannelScreen(
Text("error: ${state.error}", color = MaterialTheme.colorScheme.error)
}
else -> LazyColumn(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
else -> {
// Hoisted to outer Composable scope — LazyListScope is NOT
// @Composable so collectAsState / remember can't live inside
// the LazyColumn block.
val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState()
val filteredVideos = remember(state.videos, hideShorts) {
com.sulkta.straw.util.applyContentFilters(state.videos, hideShorts = hideShorts)
}
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = rememberBottomContentPadding(),
) {
item {
state.banner?.let { b ->
AsyncImage(
@ -129,30 +163,47 @@ fun ChannelScreen(
}
HorizontalDivider()
}
items(state.videos) { item ->
ChannelVideoRow(item) { onOpenVideo(item.url, item.title) }
items(filteredVideos) { item ->
ChannelVideoRow(
item = item,
onClick = { onOpenVideo(item.url, item.title) },
onLongClick = {
actionTarget = VideoActionTarget(
streamUrl = item.url,
title = item.title,
uploader = item.uploader,
thumbnail = item.thumbnail,
)
},
)
HorizontalDivider()
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ChannelVideoRow(item: StreamItem, onClick: () -> Unit) {
private fun ChannelVideoRow(
item: StreamItem,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
AsyncImage(
model = item.thumbnail,
contentDescription = null,
VideoThumbnail(
thumbnail = item.thumbnail,
videoUrl = item.url,
durationSeconds = item.durationSeconds,
modifier = Modifier
.width(140.dp)
.height(80.dp)
.clip(RoundedCornerShape(6.dp)),
.height(80.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
@ -164,18 +215,26 @@ private fun ChannelVideoRow(item: StreamItem, onClick: () -> Unit) {
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = buildString {
if (item.viewCount > 0) append("${formatCount(item.viewCount)} views")
if (item.durationSeconds > 0) {
if (isNotEmpty()) append(" · ")
append(formatDuration(item.durationSeconds))
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
// Don't repeat duration here — VideoThumbnail's
// bottom-right badge already shows it. Add the upload
// date so the row reads 'N views · 2 days ago' the way
// YT renders it. The earlier row was duplicating duration
// and missing the upload date on the channel page.
val meta = buildString {
if (item.viewCount > 0) append("${formatCount(item.viewCount)} views")
if (item.uploadDateRelative.isNotBlank()) {
if (isNotEmpty()) append(" · ")
append(item.uploadDateRelative)
}
}
if (meta.isNotEmpty()) {
Text(
text = meta,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
}

View file

@ -1,6 +1,10 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase U-4 / Path C-5: ChannelInfo + Videos tab moved to strawcore
* (rustypipe). The two separate ChannelInfo.getInfo + ChannelTabInfo.getInfo
* calls collapse into one Rust round-trip.
*/
package com.sulkta.straw.feature.channel
@ -8,19 +12,14 @@ package com.sulkta.straw.feature.channel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.bestThumbnail
import kotlinx.coroutines.Dispatchers
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import com.sulkta.straw.util.isAllowedYtUrl
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class ChannelUiState(
val loading: Boolean = true,
@ -30,60 +29,94 @@ data class ChannelUiState(
val avatar: String? = null,
val videos: List<StreamItem> = emptyList(),
val error: String? = null,
/**
* Tracks which channel URL the current state belongs to. Same
* activity-scoped-VM hazard as VideoDetail: a fresh nav to
* channel B sees the PREVIOUS channel's state for one composition
* frame before vm.load(B) clears it. Without this field, any
* caller that derives "this is the channel we want" from
* `state.name` (or other display fields) is reading channel A's
* data while believing it's B.
*/
val loadedUrl: String? = null,
)
class ChannelViewModel : ViewModel() {
private val _ui = MutableStateFlow(ChannelUiState())
val ui: StateFlow<ChannelUiState> = _ui.asStateFlow()
fun load(channelUrl: String) {
_ui.value = ChannelUiState(loading = true)
viewModelScope.launch {
try {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val info = withContext(Dispatchers.IO) {
ChannelInfo.getInfo(service, channelUrl)
}
// AUD-HIGH: pick the Videos tab specifically rather than
// info.tabs.firstOrNull() which is YouTube's "Home" (a
// curated mix that mostly drops via filterIsInstance).
val videosTab = info.tabs.firstOrNull {
it.contentFilters.contains(ChannelTabs.VIDEOS)
} ?: info.tabs.firstOrNull()
val videos: List<StreamItem> = if (videosTab != null) {
withContext(Dispatchers.IO) {
runCatching {
ChannelTabInfo.getInfo(service, videosTab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
.map {
StreamItem(
url = it.url,
title = it.name ?: "(no title)",
uploader = it.uploaderName ?: info.name ?: "",
uploaderUrl = it.uploaderUrl ?: channelUrl,
thumbnail = bestThumbnail(it.thumbnails),
durationSeconds = it.duration,
viewCount = it.viewCount,
)
}
}.getOrDefault(emptyList())
}
} else emptyList()
// Track the active load coroutine — same shape as
// VideoDetailViewModel. Rapid channel switches no longer race;
// the late-arriving older fetch is cancelled.
// / MED-1.
private var inFlight: Job? = null
_ui.value = ChannelUiState(
fun load(channelUrl: String) {
// Snapshot _ui once so the two reads agree.
val snap = _ui.value
if (snap.loadedUrl == channelUrl && snap.videos.isNotEmpty()) return
// extractor-emitted uploaderUrl can be
// attacker-controlled if the YT response is poisoned upstream.
// Refuse non-YT hosts at the entry point so we don't even
// issue a network call to evil.com via strawcore. Also cancel
// inFlight on rejection so a still-resolving prior load can't
// clobber the error banner.
if (!isAllowedYtUrl(channelUrl)) {
inFlight?.cancel()
inFlight = null
_ui.update {
ChannelUiState(
loading = false,
name = info.name ?: "",
subscriberCount = info.subscriberCount,
banner = bestThumbnail(info.banners),
avatar = bestThumbnail(info.avatars),
videos = videos,
error = "Unsupported URL",
loadedUrl = channelUrl,
)
}
return
}
inFlight?.cancel()
_ui.update { ChannelUiState(loading = true, loadedUrl = channelUrl) }
inFlight = viewModelScope.launch {
try {
val ch = uniffi.strawcore.channelInfo(channelUrl)
val videos = ch.videos.map { v ->
StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader,
uploaderUrl = v.uploaderUrl,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
uploadDateRelative = v.uploadDateRelative,
)
}
if (_ui.value.loadedUrl != channelUrl) return@launch
_ui.update {
ChannelUiState(
loading = false,
name = ch.name,
subscriberCount = ch.subscriberCount,
banner = ch.banner,
avatar = ch.avatar,
videos = videos,
loadedUrl = channelUrl,
)
}
} catch (t: Throwable) {
_ui.value = ChannelUiState(
loading = false,
error = t.message ?: t.javaClass.simpleName,
)
if (t is CancellationException) throw t
if (_ui.value.loadedUrl != channelUrl) return@launch
_ui.update {
ChannelUiState(
loading = false,
// Scrub before storing — UniFFI/Rust exceptions
// can embed full signed googlevideo URLs in the
// message (NetworkError::Recaptcha { url }).
error = com.sulkta.straw.util.LogDump.scrubLine(
t.message ?: t.javaClass.simpleName,
),
loadedUrl = channelUrl,
)
}
}
}
}

View file

@ -0,0 +1,554 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* NewPipe / Tubular export importer.
*
* The user picks an exported `.zip` (NewPipe writes it as
* `NewPipeData-<date>.zip`, Tubular as `TubularData-<date>.zip`).
* Inside:
* - newpipe.db Room SQLite (subscriptions, playlists, history)
* - preferences.json flat key/value of all user settings
* - newpipe.settings superseded XML form of preferences (we ignore)
*
* We populate Straw's existing stores (Subscriptions, Playlists, History,
* Settings) filtering to service_id=0 (YouTube). Other services
* (SoundCloud / PeerTube / ) are silently dropped we don't support
* them and a mixed import would surprise the user later.
*
* Resume positions (NewPipe `stream_state` table) are read but
* intentionally not persisted yet Straw has no resume-positions
* store. Counted in [ImportResult.resumePositionsSeen] so the user
* knows the data was present even if dropped.
*/
package com.sulkta.straw.feature.dataimport
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.History
import com.sulkta.straw.data.PlaylistItem
import com.sulkta.straw.data.Playlists
import com.sulkta.straw.data.SbCategory
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.data.WatchHistoryItem
import java.io.File
import java.util.zip.ZipInputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
data class ImportResult(
val subscriptionsAdded: Int,
val subscriptionsSkippedNonYt: Int,
val playlistsAdded: Int,
val playlistItemsAdded: Int,
val searchHistoryAdded: Int,
val searchHistoryAvailable: Int,
val watchHistoryAdded: Int,
val watchHistoryAvailable: Int,
val resumePositionsSeen: Int,
val settingsApplied: Int,
val warnings: List<String>,
) {
fun summary(): String = buildString {
append("Imported ")
append(subscriptionsAdded)
append(" subs")
if (subscriptionsSkippedNonYt > 0) {
append(" (skipped ")
append(subscriptionsSkippedNonYt)
append(" non-YouTube)")
}
append(", ")
append(playlistsAdded)
append(" playlist")
if (playlistsAdded != 1) append("s")
append(" (")
append(playlistItemsAdded)
append(" videos), ")
append(watchHistoryAdded)
append("/")
append(watchHistoryAvailable)
append(" watch history, ")
append(searchHistoryAdded)
append("/")
append(searchHistoryAvailable)
append(" searches, ")
append(settingsApplied)
append(" settings.")
if (resumePositionsSeen > 0) {
append(" Resume positions (")
append(resumePositionsSeen)
append(") not yet supported — dropped.")
}
if (warnings.isNotEmpty()) {
append("\n\nWarnings:\n")
warnings.forEach { append(""); append(it); append("\n") }
}
}
}
object SettingsImport {
// YouTube only — Straw doesn't extract from other services.
private const val YT_SERVICE_ID = 0
// The allowlist itself lives in util.YtUrl now — VideoDetailViewModel
// also gates auto-channelInfo + recordWatch through it.
private fun isAllowedYtUrl(url: String): Boolean =
com.sulkta.straw.util.isAllowedYtUrl(url)
suspend fun run(context: Context, zipUri: Uri): Result<ImportResult> =
withContext(Dispatchers.IO) {
// runInner is suspend (it switches to NonCancellable for
// cleanup). Plain runCatching would swallow a user-back
// CancellationException and surface it as a normal
// failure with a misleading banner.
com.sulkta.straw.util.runCatchingCancellable {
runInner(context, zipUri)
}
}
/**
* Sweep stale import work-dirs left behind by a previous run that
* was killed mid-extraction. CRIT from the security audit:
* a force-killed import leaves the user's full newpipe.db sitting
* in cacheDir indefinitely. StrawApp.onCreate calls this on every
* cold start.
*/
fun sweepStale(context: Context) {
runCatching {
context.cacheDir.listFiles { f ->
f.isDirectory && f.name.startsWith("newpipe-import-")
}?.forEach { it.deleteRecursively() }
}
}
private suspend fun runInner(context: Context, zipUri: Uri): ImportResult {
val warnings = mutableListOf<String>()
// createTempFile returns an unguessable name and 0600 perms by
// default, replacing the predictable currentTimeMillis suffix
// that an attacker could pre-create a symlink at.
val workDir = File.createTempFile("newpipe-import-", "", context.cacheDir).also {
it.delete(); it.mkdirs()
}
try {
val (dbFile, prefsJson) = extractZip(context, zipUri, workDir, warnings)
val subsResult = if (dbFile != null) importSubscriptions(dbFile) else SubsResult(0, 0)
val plResult = if (dbFile != null) importPlaylists(dbFile) else PlResult(0, 0)
val histResult = if (dbFile != null) importHistory(dbFile) else HistResult(0, 0, 0, 0, 0)
val settingsResult = if (prefsJson != null) importSettings(prefsJson) else 0
return ImportResult(
subscriptionsAdded = subsResult.added,
subscriptionsSkippedNonYt = subsResult.skipped,
playlistsAdded = plResult.playlists,
playlistItemsAdded = plResult.items,
searchHistoryAdded = histResult.searches,
searchHistoryAvailable = histResult.searchesAvailable,
watchHistoryAdded = histResult.watchesAdded,
watchHistoryAvailable = histResult.watchesAvailable,
resumePositionsSeen = histResult.resumePositions,
settingsApplied = settingsResult,
warnings = warnings,
)
} finally {
// NonCancellable guarantees the cleanup runs even when the
// outer coroutine was cancelled — without it a user
// navigating away mid-import (or low-memory killer firing)
// left the full newpipe.db in cacheDir until the next
// cold-start sweep.
withContext(NonCancellable) {
workDir.deleteRecursively()
}
}
}
// Defense against zip-bomb / malformed exports.
private const val MAX_DB_BYTES: Long = 256L * 1024 * 1024
private const val MAX_PREFS_BYTES: Long = 1L * 1024 * 1024
private const val MAX_ZIP_ENTRIES: Int = 64
private fun extractZip(
context: Context,
zipUri: Uri,
workDir: File,
warnings: MutableList<String>,
): Pair<File?, JsonObject?> {
var dbFile: File? = null
var prefs: JsonObject? = null
var entryCount = 0
context.contentResolver.openInputStream(zipUri)?.use { input ->
ZipInputStream(input).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
entryCount++
if (entryCount > MAX_ZIP_ENTRIES) {
warnings += "archive has >$MAX_ZIP_ENTRIES entries — aborting"
return null to null
}
when (entry.name) {
"newpipe.db" -> {
// Reject duplicate entries — a malicious zip
// can put a benign db first and a hostile
// second; ZipInputStream walks in order and
// would overwrite.
if (dbFile != null) {
warnings += "duplicate newpipe.db in archive — aborting"
return null to null
}
val out = File(workDir, "newpipe.db")
val written = copyBounded(zip, out, MAX_DB_BYTES)
if (written < 0L) {
warnings += "newpipe.db exceeds ${MAX_DB_BYTES / (1024 * 1024)} MB — aborting"
out.delete()
return null to null
}
dbFile = out
}
"preferences.json" -> {
if (prefs != null) {
warnings += "duplicate preferences.json in archive — aborting"
return null to null
}
val bytes = readBoundedBytes(zip, MAX_PREFS_BYTES)
if (bytes == null) {
warnings += "preferences.json exceeds ${MAX_PREFS_BYTES / 1024} KB — skipping"
} else {
prefs = runCatching {
Json.parseToJsonElement(bytes.decodeToString()) as? JsonObject
}.getOrNull()
if (prefs == null) warnings += "preferences.json present but unparseable"
}
}
// newpipe.settings is the legacy XML form; preferences.json
// supersedes it in every modern export. Skip.
else -> { /* ignore other entries */ }
}
zip.closeEntry()
}
}
} ?: error("Could not open the selected file")
if (dbFile == null) warnings += "newpipe.db not found in archive — most data skipped"
if (prefs == null) warnings += "preferences.json not found — settings not migrated"
return dbFile to prefs
}
/**
* Bounded copy. Returns bytes-written on success, -1 if `cap` was
* exceeded. Used instead of `copyTo` so a 16 GB zip-bomb doesn't
* fill the user's cacheDir before we notice.
*/
private fun copyBounded(src: java.io.InputStream, dst: File, cap: Long): Long {
dst.outputStream().use { os ->
val buf = ByteArray(64 * 1024)
var total = 0L
while (true) {
val n = src.read(buf)
if (n <= 0) break
total += n
if (total > cap) return -1L
os.write(buf, 0, n)
}
return total
}
}
private fun readBoundedBytes(src: java.io.InputStream, cap: Long): ByteArray? {
val baos = java.io.ByteArrayOutputStream()
val buf = ByteArray(16 * 1024)
var total = 0L
while (true) {
val n = src.read(buf)
if (n <= 0) break
total += n
if (total > cap) return null
baos.write(buf, 0, n)
}
return baos.toByteArray()
}
private data class SubsResult(val added: Int, val skipped: Int)
private fun importSubscriptions(dbFile: File): SubsResult {
val store = Subscriptions.get()
// Cap input row count too — hostile NewPipe export with a
// million rows would still walk the cursor fully without this.
val maxRows = 10_000
var skipped = 0
val staged = mutableListOf<ChannelRef>()
openDb(dbFile).use { db ->
db.rawQuery(
"SELECT url, name, avatar_url, service_id FROM subscriptions LIMIT $maxRows",
null,
).use { c ->
while (c.moveToNext()) {
val serviceId = c.getInt(3)
if (serviceId != YT_SERVICE_ID) {
skipped++
continue
}
val url = c.getString(0) ?: continue
if (!isAllowedYtUrl(url)) {
skipped++
continue
}
val name = c.getString(1) ?: continue
val avatar = c.getString(2)
staged += ChannelRef(url = url, name = name, avatar = avatar)
}
}
}
// Single dedup + single persist regardless of N.
val added = store.addAll(staged)
return SubsResult(added, skipped)
}
private data class PlResult(val playlists: Int, val items: Int)
private fun importPlaylists(dbFile: File): PlResult {
val store = Playlists.get()
var playlistsAdded = 0
var itemsAdded = 0
openDb(dbFile).use { db ->
val playlistRows = mutableListOf<Pair<Long, String>>()
// Hard caps so a malicious export with millions of rows
// doesn't walk an unbounded cursor into memory.
db.rawQuery("SELECT uid, name FROM playlists LIMIT 256", null).use { c ->
while (c.moveToNext()) {
val uid = c.getLong(0)
val name = c.getString(1) ?: "Untitled"
playlistRows += uid to name
}
}
for ((uid, name) in playlistRows) {
val items = mutableListOf<PlaylistItem>()
db.rawQuery(
"""
SELECT s.url, s.title, s.thumbnail_url, s.uploader, s.service_id
FROM playlist_stream_join j
JOIN streams s ON s.uid = j.stream_id
WHERE j.playlist_id = ?
ORDER BY j.join_index
LIMIT 5000
""".trimIndent(),
arrayOf(uid.toString()),
).use { c ->
while (c.moveToNext()) {
if (c.getInt(4) != YT_SERVICE_ID) continue
val streamUrl = c.getString(0) ?: continue
if (!isAllowedYtUrl(streamUrl)) continue
items += PlaylistItem(
streamUrl = streamUrl,
title = c.getString(1) ?: "(no title)",
thumbnail = c.getString(2),
uploader = c.getString(3) ?: "",
addedAt = System.currentTimeMillis(),
)
}
}
if (items.isEmpty()) continue
// Bulk import: one CAS + one SP write per playlist
// instead of (1 create + N addItem) writes. Old shape
// produced ~10k SP commits on a 100×100 export, plus
// O(N²) work in addItem's per-call linear scan over
// every playlist.
store.importPlaylist(name, items)
playlistsAdded++
itemsAdded += items.size
}
}
return PlResult(playlistsAdded, itemsAdded)
}
private data class HistResult(
val watchesAdded: Int,
val watchesAvailable: Int,
val searches: Int,
val searchesAvailable: Int,
val resumePositions: Int,
)
private fun importHistory(dbFile: File): HistResult {
val historyStore = History.get()
var watchesSeen = 0
var watchesAvailable = 0
var searchesSeen = 0
var resumePositions = 0
var watchesAdded = 0
var searchesAdded = 0
openDb(dbFile).use { db ->
// Search history — feed oldest first so the store ends up with
// the most-recent on top after its own dedup + take(MAX).
// Stage + bulk-write —:
// per-row recordSearch was N SP writes on potentially
// 100k+ rows. The SELECT also lacked a LIMIT; added now.
val stagedSearches = mutableListOf<String>()
db.rawQuery(
"SELECT search FROM search_history WHERE service_id=? ORDER BY creation_date ASC LIMIT 50000",
arrayOf(YT_SERVICE_ID.toString()),
).use { c ->
while (c.moveToNext()) {
val q = c.getString(0) ?: continue
stagedSearches += q
searchesSeen++
}
}
searchesAdded = historyStore.recordAllSearches(stagedSearches)
// Watch history — newest first via stream_history.access_date,
// joined to streams for the metadata we need.
// recordWatch caps internally; we just stop counting "added" once
// we've replayed Straw's MAX rows. (The store reverses to put
// most-recent on top — so we feed it oldest-first to match.)
db.rawQuery("SELECT COUNT(*) FROM stream_history", null).use { c ->
if (c.moveToNext()) watchesAvailable = c.getInt(0)
}
// Stage rows in memory, then one bulk write — same DoS
// mitigation as importSubscriptions. recordWatch did N SP
// writes and an O(N) dedup per row.
val staged = mutableListOf<WatchHistoryItem>()
db.rawQuery(
"""
SELECT s.url, s.title, s.uploader, s.thumbnail_url, h.access_date, s.service_id
FROM stream_history h
JOIN streams s ON s.uid = h.stream_id
ORDER BY h.access_date ASC
LIMIT 50000
""".trimIndent(),
null,
).use { c ->
while (c.moveToNext()) {
if (c.getInt(5) != YT_SERVICE_ID) continue
val url = c.getString(0) ?: continue
if (!isAllowedYtUrl(url)) continue
val title = c.getString(1) ?: continue
val uploader = c.getString(2) ?: ""
val thumb = c.getString(3)
val videoId = extractYtVideoId(url) ?: continue
staged += WatchHistoryItem(
url = url,
videoId = videoId,
title = title,
uploader = uploader,
thumbnail = thumb,
watchedAt = c.getLong(4),
)
watchesSeen++
}
}
watchesAdded = historyStore.recordAllWatches(staged)
// Resume positions — counted, not stored. Future task hooks into
// a ResumePositionsStore.
db.rawQuery("SELECT COUNT(*) FROM stream_state", null).use { c ->
if (c.moveToNext()) resumePositions = c.getInt(0)
}
}
// recordAllWatches / recordAllSearches return the real
// added count (counts fresh videoIds / queries that landed,
// ignoring duplicates and pre-saturated-store truncation).
// / MED-2 — previous size_after
// size_before reported 0 when the store was already at cap
// even when 20 fresh imports actually landed.
return HistResult(
watchesAdded = watchesAdded,
watchesAvailable = watchesAvailable.takeIf { it > 0 } ?: watchesSeen,
searches = searchesAdded,
resumePositions = resumePositions,
searchesAvailable = searchesSeen,
)
}
private fun importSettings(prefs: JsonObject): Int {
val settings = Settings.get()
var applied = 0
// SponsorBlock: master toggle gates the categories. If disabled in
// NewPipe, leave Straw's categories alone (they have a non-empty
// default). If enabled, sync each category boolean.
val sbMaster = prefs.boolOrNull("sponsor_block_enable")
if (sbMaster == true) {
val targets = mapOf(
"sponsor_block_category_sponsor" to SbCategory.Sponsor,
"sponsor_block_category_self_promo" to SbCategory.SelfPromo,
"sponsor_block_category_intro" to SbCategory.Intro,
"sponsor_block_category_outro" to SbCategory.Outro,
"sponsor_block_category_interaction" to SbCategory.Interaction,
"sponsor_block_category_music" to SbCategory.MusicOfftopic,
"sponsor_block_category_filler" to SbCategory.Filler,
)
val current = settings.sbCategories.value
for ((key, cat) in targets) {
val want = prefs.boolOrNull(key) ?: continue
val have = cat in current
// Only count an applied toggle when it actually
// changed something. Prior shape counted every
// observed key, inflating the import summary to
// "12 settings applied" when only 2 changed.
if (want != have) {
settings.toggle(cat)
applied++
}
}
}
// Default resolution: NewPipe values like "720p60", "1080p", "Best
// resolution". Map down to Straw's discrete ceilings.
prefs.stringOrNull("default_resolution")?.let { raw ->
val r = parseResolution(raw)
if (r != null) {
settings.setMaxResolution(r)
applied++
}
}
return applied
}
private fun parseResolution(raw: String): com.sulkta.straw.data.MaxResolution? {
val n = Regex("(\\d+)").find(raw)?.groupValues?.get(1)?.toIntOrNull()
?: return when (raw.lowercase()) {
"best resolution", "best", "highest" -> com.sulkta.straw.data.MaxResolution.Auto
else -> null
}
return when {
n >= 1080 -> com.sulkta.straw.data.MaxResolution.P1080
n >= 720 -> com.sulkta.straw.data.MaxResolution.P720
n >= 480 -> com.sulkta.straw.data.MaxResolution.P480
n >= 360 -> com.sulkta.straw.data.MaxResolution.P360
else -> com.sulkta.straw.data.MaxResolution.P144
}
}
private fun openDb(dbFile: File): SQLiteDatabase =
SQLiteDatabase.openDatabase(
dbFile.absolutePath,
/* factory = */ null,
SQLiteDatabase.OPEN_READONLY,
)
// YouTube URL patterns we need to parse for the videoId column on
// WatchHistoryItem. Cover the watch?v= form (canonical), youtu.be
// shortlinks, and embed/. Reject anything we can't parse rather than
// inventing IDs.
private val YT_ID = Regex(
"(?:youtu\\.be/|youtube(?:-nocookie)?\\.com/(?:watch\\?(?:.*&)?v=|embed/|v/|shorts/))([A-Za-z0-9_-]{6,15})",
)
private fun extractYtVideoId(url: String): String? =
YT_ID.find(url)?.groupValues?.get(1)
private fun JsonObject.boolOrNull(key: String): Boolean? =
runCatching { this[key]?.jsonPrimitive?.boolean }.getOrNull()
private fun JsonObject.stringOrNull(key: String): String? =
runCatching { this[key]?.jsonPrimitive?.contentOrNull }.getOrNull()
}

View file

@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Pick the playable URLs from a strawcore StreamInfo. Lives outside
* VideoDetailViewModel so the queue path can call it too.
*/
package com.sulkta.straw.feature.detail
import com.sulkta.straw.data.Settings
import com.sulkta.straw.net.SbSegment
/**
* Extract the YouTube video ID from a watch URL. Handles the common
* forms: `youtube.com/watch?v=XXXXXXXXXXX`, `youtu.be/X...`, and
* `youtube.com/shorts/X...`. Returns null when nothing matches.
*
* Centralized here so the autoplay + history + import paths all
* resolve videoIds the same way. Duplicates an earlier per-file regex
* (`StrawHome.kt:VIDEO_ID_RE`) that one can fold into this when next
* touched.
*/
private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&#].*)?$")
fun extractYtVideoId(url: String): String? =
VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1)?.takeIf { it.isNotBlank() }
/**
* Convert a raw strawcore.StreamInfo into the picked-URL DTO the
* MediaController wants. Honors Settings.maxResolution cap-fit if
* possible, otherwise the closest-to-cap fallback (lowest height) so
* we don't blow a user's data plan when only above-cap streams exist.
*
* `segments` is the SponsorBlock list to bake into the resulting
* ResolvedPlayback; pass emptyList() when no SB is desired (the queue
* path doesn't pre-fetch SB for queued items).
*/
fun resolveStreamPlayback(
info: uniffi.strawcore.StreamInfo,
segments: List<SbSegment> = emptyList(),
): ResolvedPlayback {
val maxRes = Settings.get().maxResolution.value.ceiling
fun pickVideo(streams: List<uniffi.strawcore.VideoStreamItem>): String? {
if (streams.isEmpty()) return null
val capped = streams.filter { it.height <= maxRes }
return if (capped.isNotEmpty()) {
capped.maxByOrNull { it.bitrate }?.url
} else {
streams.minByOrNull { it.height }?.url
}
}
return ResolvedPlayback(
title = info.title,
videoUrl = pickVideo(info.videoOnly),
audioUrl = info.audioOnly.maxByOrNull { it.bitrate }?.url,
combinedUrl = pickVideo(info.combined),
dashMpdUrl = info.dashMpdUrl?.takeIf { it.isNotBlank() },
hlsUrl = info.hlsUrl?.takeIf { it.isNotBlank() },
segments = segments,
)
}

View file

@ -5,132 +5,292 @@
package com.sulkta.straw.feature.detail
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Intent
import android.os.Build
import android.util.Rational
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Headphones
import androidx.compose.material.icons.filled.PictureInPictureAlt
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import android.content.Intent
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import com.sulkta.straw.feature.download.DownloadKind
import com.sulkta.straw.feature.download.Downloader
import com.sulkta.straw.feature.player.PlayerViewModel
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
import coil3.compose.AsyncImage
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.OverlayChromeColor
import com.sulkta.straw.OverlayDimColor
import com.sulkta.straw.data.PlaylistItem
import com.sulkta.straw.feature.playlist.SaveToPlaylistDialog
import com.sulkta.straw.feature.download.DownloadKind
import com.sulkta.straw.feature.download.Downloader
import com.sulkta.straw.feature.player.LocalStrawController
import com.sulkta.straw.feature.player.NowPlaying
import com.sulkta.straw.feature.player.VideoThumbnail
import com.sulkta.straw.feature.player.setPlayingFrom
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.LogDump
import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.util.formatCount
import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.stripHtml
@OptIn(ExperimentalLayoutApi::class, UnstableApi::class)
@Composable
fun VideoDetailScreen(
streamUrl: String,
initialTitle: String,
onPlay: () -> Unit,
onMinimize: () -> Unit,
onOpenChannel: (channelUrl: String, name: String) -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
vm: VideoDetailViewModel = viewModel(),
) {
val state by vm.ui.collectAsStateWithLifecycle()
val context = LocalContext.current
val controller = LocalStrawController.current
val activity = context as? Activity
var showDownloadDialog by remember { mutableStateOf(false) }
// Inline-play state. Resets when the user navigates to a different
// video (keyed on streamUrl).
var inlinePlaying by remember(streamUrl) { mutableStateOf(false) }
var showSaveToPlaylistDialog by remember { mutableStateOf(false) }
var actionTarget by remember { mutableStateOf<com.sulkta.straw.feature.playlist.VideoActionTarget?>(null) }
actionTarget?.let { t ->
com.sulkta.straw.feature.playlist.VideoActionsSheet(
target = t,
onDismiss = { actionTarget = null },
)
}
// Inline-play state resets when navigating to a different video.
// Defaults to TRUE when:
// * the shared MediaController is already streaming this URL
// (back-from-fullscreen — without this the page renders as
// "freshly loaded" while audio keeps playing in the
// background), or
// * the user has Settings → Auto-start playback enabled (cold
// open from search / subs / wherever immediately plays).
// Off + fresh URL → thumbnail + Play overlay, user taps to start.
val autoStart by Settings.get().autoStartPlayback.collectAsState()
var inlinePlaying by remember(streamUrl) {
mutableStateOf(
NowPlaying.current.value?.streamUrl == streamUrl || autoStart,
)
}
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
// The Background button (and the fullscreen audio-only toggle)
// disable the video track on the shared controller, and that state
// sticks. Entering detail = user wants to watch the video — wipe the
// override and let DASH pick the highest renderable video again.
LaunchedEffect(controller, streamUrl) {
controller?.let {
it.trackSelectionParameters = TrackSelectionParameters.Builder(context).build()
}
}
// Swipe-down to minimize. The drag handle is the inline player surface
// at the top of the page; we translate the WHOLE page with it so the
// motion reads as "the video is being tucked away" rather than "this
// one widget slid."
//
// Two-state pattern so the drag stays smooth at 120fps:
// liveDrag — mutableFloatStateOf updated SYNCHRONOUSLY in
// rememberDraggableState's callback. One state write
// per pointer event, no coroutine spawn.
// releaseAnim — Animatable driven by a single coroutine that
// runs only when the finger leaves (spring back
// if short, slide off-screen + onMinimize if past
// threshold or flung).
// graphicsLayer reads whichever is active via the `dragging` flag.
// The old single-Animatable / scope.launch-per-pixel pattern
// raced coroutines for every drag delta and stuttered on fast
// gestures; this doesn't.
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val dismissThresholdPx = with(density) { 140.dp.toPx() }
val flingVelocityThreshold = with(density) { 600.dp.toPx() }
val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() }
// mutableFloatStateOf avoids boxing on every drag delta — the
// draggable callback fires 100+ times/s on a fast swipe.
var liveDrag by remember { mutableFloatStateOf(0f) }
var dragging by remember { mutableStateOf(false) }
val releaseAnim = remember { Animatable(0f) }
val draggableState = rememberDraggableState { delta ->
liveDrag = (liveDrag + delta).coerceAtLeast(0f)
}
val playerDragModifier = Modifier.draggable(
orientation = Orientation.Vertical,
state = draggableState,
onDragStarted = {
releaseAnim.stop()
liveDrag = releaseAnim.value
dragging = true
},
onDragStopped = { velocity ->
val shouldDismiss =
liveDrag > dismissThresholdPx || velocity > flingVelocityThreshold
releaseAnim.snapTo(liveDrag)
dragging = false
if (shouldDismiss) {
// Slide the rest of the way off-screen, then pop. The
// pop happens AFTER the animation so the user sees the
// page leave under their finger instead of a hard cut.
releaseAnim.animateTo(
screenHeightPx,
tween(durationMillis = 220, easing = FastOutLinearInEasing),
)
onMinimize()
} else {
releaseAnim.animateTo(
0f,
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow,
),
)
}
liveDrag = 0f
},
)
Column(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
val y = if (dragging) liveDrag else releaseAnim.value
translationY = y
val p = (y / dismissThresholdPx).coerceIn(0f, 1f)
alpha = 1f - p * 0.4f
val s = 1f - p * 0.08f
scaleX = s
scaleY = s
}
.statusBarsPadding()
.verticalScroll(rememberScrollState())
.padding(16.dp),
.verticalScroll(rememberScrollState()),
) {
when {
state.loading -> Box(
modifier = Modifier.fillMaxWidth().padding(top = 64.dp),
modifier = Modifier
.fillMaxWidth()
.padding(top = 64.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
state.error != null -> Text(
"error: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
else -> {
val d = state.detail ?: return@Column
// Tap the thumbnail to play inline. Fullscreen button (top-right
// overlay on the inline player) jumps to the fullscreen Player
// screen which has the full toolset.
// Guard against vm's activity-scoped staleness — on a
// fresh navigation A → B, the shared VM still holds
// A's detail/resolved for one composition frame before
// vm.load(B)'s reset propagates. Without this gate, the
// InlinePlayer's LaunchedEffect would fire with
// streamUrl=B but resolved=A's URLs and play A under
// B's chrome — symptom is the detail page showing the
// new video while the audio is still the old one.
if (state.loadedUrl != streamUrl) return@Column
// Player surface — edge-to-edge, NewPipe/YouTube style.
// Lives outside the 16dp horizontal padding so the
// thumbnail fills the screen width with no gutters.
if (inlinePlaying) {
InlinePlayer(
streamUrl = streamUrl,
title = d.title,
uploader = d.uploader,
thumbnail = d.thumbnail,
onFullscreen = onPlay,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(8.dp))
.background(Color.Black),
.background(Color.Black)
.then(playerDragModifier),
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(8.dp))
.clickable { inlinePlaying = true },
.background(Color.Black)
.clickable { inlinePlaying = true }
.then(playerDragModifier),
contentAlignment = Alignment.Center,
) {
AsyncImage(
@ -141,8 +301,8 @@ fun VideoDetailScreen(
Box(
modifier = Modifier
.size(64.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(Color(0xCC000000)),
.clip(CircleShape)
.background(OverlayDimColor),
contentAlignment = Alignment.Center,
) {
Icon(
@ -154,27 +314,79 @@ fun VideoDetailScreen(
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// ── Title + uploader ─────────────────────────────────────
// Everything below the player gets the side gutters
// back; player itself remains edge-to-edge.
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(
text = d.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
val uploaderClickable = d.uploaderUrl != null
Text(
text = d.uploader,
style = MaterialTheme.typography.bodyMedium,
color = if (uploaderClickable) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = if (uploaderClickable) Modifier.clickable {
onOpenChannel(d.uploaderUrl!!, d.uploader)
} else Modifier,
)
Spacer(modifier = Modifier.height(8.dp))
val uploaderUrl = d.uploaderUrl
// Channel row: avatar + name (larger, clickable when we
// have a uploaderUrl) + Subscribe / Subscribed toggle.
// Matches the YouTube/NewPipe layout below the title.
val subs by Subscriptions.get().subs.collectAsStateWithLifecycle()
val isSubscribed = uploaderUrl != null && subs.any { it.url == uploaderUrl }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
if (!d.uploaderAvatar.isNullOrBlank()) {
AsyncImage(
model = d.uploaderAvatar,
contentDescription = null,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.then(
if (uploaderUrl != null)
Modifier.clickable { onOpenChannel(uploaderUrl, d.uploader) }
else Modifier
),
)
Spacer(modifier = Modifier.width(10.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = d.uploader,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = if (uploaderUrl != null) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = if (uploaderUrl != null) Modifier
.clickable { onOpenChannel(uploaderUrl, d.uploader) }
.padding(vertical = 4.dp)
else Modifier.padding(vertical = 4.dp),
)
if (d.uploaderSubscriberCount > 0) {
Text(
text = "${formatCount(d.uploaderSubscriberCount)} subscribers",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (uploaderUrl != null) {
val onSubClick = {
Subscriptions.get().toggle(
ChannelRef(
url = uploaderUrl,
name = d.uploader,
avatar = d.uploaderAvatar,
),
)
}
if (isSubscribed) {
OutlinedButton(onClick = onSubClick) { Text("Subscribed") }
} else {
Button(onClick = onSubClick) { Text("Subscribe") }
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// ── Engagement row: views + RYD likes/dislikes ───────────
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
@ -208,8 +420,113 @@ fun VideoDetailScreen(
}
Spacer(modifier = Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(onClick = onPlay) { Text("Play") }
OutlinedButton(
onClick = {
val c = controller
if (c == null) {
Toast.makeText(context, "no player", Toast.LENGTH_SHORT).show()
return@OutlinedButton
}
// Make sure the controller is playing this video
// before backing out — otherwise dropping to the
// minibar would dismiss into an empty slot.
// Optimization: skip the MediaItem build if
// the controller is already on this URL.
// claim() in setPlayingFrom is the
// authoritative race-free guard — this
// check is just to avoid the work.
if (NowPlaying.current.value?.streamUrl != streamUrl) {
val r = state.resolved
if (r == null) {
Toast.makeText(context, "stream not ready", Toast.LENGTH_SHORT).show()
return@OutlinedButton
}
c.setPlayingFrom(
streamUrl = streamUrl,
title = d.title,
uploader = d.uploader,
thumbnail = d.thumbnail,
resolved = r,
uploaderUrl = d.uploaderUrl,
)
}
// Audio-only: drop video track. Foreground
// service keeps the audio going; minibar takes
// over once we pop off the detail screen.
c.trackSelectionParameters = TrackSelectionParameters.Builder(context)
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
.build()
if (!c.isPlaying) c.play()
onMinimize()
},
) {
Icon(
imageVector = Icons.Filled.Headphones,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text("Background")
}
OutlinedButton(
onClick = {
if (activity == null) {
Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show()
return@OutlinedButton
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show()
return@OutlinedButton
}
// PiP into nothing isn't useful — bail with a
// Toast if there's no controller / no resolved
// playback to push into it.
val c = controller
val r = state.resolved
if (c == null || r == null) {
Toast.makeText(context, "stream not ready", Toast.LENGTH_SHORT).show()
return@OutlinedButton
}
// Optimization: skip the MediaItem build if
// the controller is already on this URL.
// claim() in setPlayingFrom is the
// authoritative race-free guard — this
// check is just to avoid the work.
if (NowPlaying.current.value?.streamUrl != streamUrl) {
c.setPlayingFrom(
streamUrl = streamUrl,
title = d.title,
uploader = d.uploader,
thumbnail = d.thumbnail,
resolved = r,
uploaderUrl = d.uploaderUrl,
)
}
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
.build()
runCatching { activity.enterPictureInPictureMode(params) }
.onSuccess { ok ->
if (!ok) Toast.makeText(context, "PiP refused", Toast.LENGTH_LONG).show()
}
.onFailure { t ->
Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show()
}
},
) {
Icon(
imageVector = Icons.Filled.PictureInPictureAlt,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text("Popout")
}
OutlinedButton(onClick = {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
@ -221,20 +538,21 @@ fun VideoDetailScreen(
OutlinedButton(onClick = { showDownloadDialog = true }) {
Text("Download")
}
OutlinedButton(onClick = { showSaveToPlaylistDialog = true }) {
Text("Save")
}
}
Spacer(modifier = Modifier.height(20.dp))
// ── Description ──────────────────────────────────────────
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
// AUD-MED: cap input length before regex passes — defends
// against ANR on multi-MB descriptions.
// Cap input length before regex passes — defends against
// ANR on multi-MB descriptions.
Text(
text = stripHtml(d.description.take(20_000)).take(2000),
style = MaterialTheme.typography.bodySmall,
)
// ── Recommended ──────────────────────────────────────────
if (d.related.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
Text(
@ -244,12 +562,22 @@ fun VideoDetailScreen(
)
Spacer(modifier = Modifier.height(8.dp))
d.related.take(20).forEach { rel ->
RelatedRow(rel) { onOpenVideo(rel.url, rel.title) }
androidx.compose.material3.HorizontalDivider()
RelatedRow(
item = rel,
onClick = { onOpenVideo(rel.url, rel.title) },
onLongClick = {
actionTarget = com.sulkta.straw.feature.playlist.VideoActionTarget(
streamUrl = rel.url,
title = rel.title,
uploader = rel.uploader,
thumbnail = rel.thumbnail,
)
},
)
HorizontalDivider()
}
}
// ── More from <uploader> ─────────────────────────────────
if (d.moreFromChannel.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
Text(
@ -260,11 +588,34 @@ fun VideoDetailScreen(
)
Spacer(modifier = Modifier.height(8.dp))
d.moreFromChannel.take(20).forEach { item ->
RelatedRow(item) { onOpenVideo(item.url, item.title) }
androidx.compose.material3.HorizontalDivider()
RelatedRow(
item = item,
onClick = { onOpenVideo(item.url, item.title) },
onLongClick = {
actionTarget = com.sulkta.straw.feature.playlist.VideoActionTarget(
streamUrl = item.url,
title = item.title,
uploader = item.uploader,
thumbnail = item.thumbnail,
)
},
)
HorizontalDivider()
}
}
if (showSaveToPlaylistDialog) {
SaveToPlaylistDialog(
item = PlaylistItem(
streamUrl = streamUrl,
title = d.title,
thumbnail = d.thumbnail,
uploader = d.uploader,
),
onDismiss = { showSaveToPlaylistDialog = false },
)
}
if (showDownloadDialog) {
val info = state.streamInfo
AlertDialog(
@ -284,10 +635,7 @@ fun VideoDetailScreen(
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val audio = info?.audioStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.url
if (audio != null) {
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
@ -298,14 +646,8 @@ fun VideoDetailScreen(
showDownloadDialog = false
}) { Text("Audio") }
Button(onClick = {
val video = info?.videoStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
?: info?.videoOnlyStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
val video = info?.combined?.maxByOrNull { it.bitrate }?.url
?: info?.videoOnly?.maxByOrNull { it.bitrate }?.url
if (video != null) {
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
val msg = if (id > 0) "video queued" else "download refused (bad URL)"
@ -318,37 +660,44 @@ fun VideoDetailScreen(
}
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = { showDownloadDialog = false }) {
TextButton(onClick = { showDownloadDialog = false }) {
Text("Cancel")
}
},
)
}
} // close inner Column (padded body)
}
}
// Leave room at the bottom for the system nav bar so the last
// related video doesn't tuck under the gesture pill / 3-button
// nav. Compose's `navigationBarsPadding` would push the whole
// surface up; we want the scroll to extend past it instead.
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RelatedRow(
item: com.sulkta.straw.feature.search.StreamItem,
item: StreamItem,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.Top,
) {
AsyncImage(
model = item.thumbnail,
contentDescription = null,
VideoThumbnail(
thumbnail = item.thumbnail,
videoUrl = item.url,
durationSeconds = item.durationSeconds,
modifier = Modifier
.width(140.dp)
.height(80.dp)
.clip(RoundedCornerShape(6.dp)),
.height(80.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
@ -357,125 +706,191 @@ private fun RelatedRow(
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = buildString {
append(item.uploader)
if (item.viewCount > 0) {
append(" · ")
append(formatViews(item.viewCount))
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
// Build the metadata line from whatever's available.
// channelInfo-sourced items (More from channel) come back
// with uploader="" because the channel page doesn't repeat
// the uploader name on each row — it's implicit. Skip
// empty pieces with the leading-separator dance so we
// never end up with " · viewCount" or trailing dots.
// Earlier shape was leaving an empty metadata line on
// More-from-channel rows.
val meta = buildString {
if (item.uploader.isNotBlank()) append(item.uploader)
if (item.viewCount > 0) {
if (isNotEmpty()) append(" · ")
append(formatViews(item.viewCount))
}
if (item.uploadDateRelative.isNotBlank()) {
if (isNotEmpty()) append(" · ")
append(item.uploadDateRelative)
}
}
if (meta.isNotEmpty()) {
Text(
text = meta,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/**
* Inline player embedded in the 16:9 thumbnail box on VideoDetailScreen.
* Uses its own ExoPlayer + PlayerView (with the built-in controller for
* play/pause/seek). A small fullscreen pill in the top-right hops the user
* to the fullscreen PlayerScreen for the full toolset (speed picker, audio-
* only, share, PiP, background). Player is released when the composable
* leaves composition (navigate back or away from VideoDetail).
* Inline player surface inside VideoDetail's 16:9 thumbnail box. Renders
* a PlayerView bound to the shared LocalStrawController the same
* player as the fullscreen PlayerScreen and the minibar overlay. The
* pill hops to fullscreen; playback continues unchanged. There is
* nothing to release here: the controller is process-wide, and the
* PlayerView's surface is detached on dispose via onRelease.
*/
@OptIn(UnstableApi::class)
@Composable
private fun InlinePlayer(
streamUrl: String,
title: String,
uploader: String,
thumbnail: String?,
onFullscreen: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val playerVm: PlayerViewModel = viewModel()
val state by playerVm.ui.collectAsStateWithLifecycle()
LaunchedEffect(streamUrl) { playerVm.resolve(streamUrl) }
val exoPlayer = remember {
ExoPlayer.Builder(context)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build(),
/* handleAudioFocus = */ true,
)
.build()
}
DisposableEffect(Unit) {
onDispose { exoPlayer.release() }
}
val controller = LocalStrawController.current
val vm: VideoDetailViewModel = viewModel()
val state by vm.ui.collectAsStateWithLifecycle()
// Push the resolved stream into the shared controller if it isn't
// already playing this URL. We don't kick off a new fetch — the
// outer VideoDetailScreen already called vm.load(streamUrl).
//
// retryVersion lets the user manually re-fire setPlayingFrom after
// a playback error. Without it, the screen used to lock into the
// thumbnail+spinner branch once NowPlaying.clear() fired from
// onPlayerError.
val resolved = state.resolved
LaunchedEffect(resolved) {
var retryVersion by remember(streamUrl) { mutableIntStateOf(0) }
LaunchedEffect(controller, resolved, streamUrl, retryVersion) {
val c = controller ?: return@LaunchedEffect
val r = resolved ?: return@LaunchedEffect
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(NewPipeDownloader.USER_AGENT)
.setAllowCrossProtocolRedirects(true)
val source = when {
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.dashMpdUrl))
r.hlsUrl != null -> HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.hlsUrl))
r.combinedUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.combinedUrl))
r.videoUrl != null && r.audioUrl != null -> {
val v = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.videoUrl))
val a = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.audioUrl))
MergingMediaSource(v, a)
}
r.videoUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.videoUrl))
else -> null
}
if (source != null) {
exoPlayer.setMediaSource(source)
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
// Optimization, not safety. claim() guards the race.
if (NowPlaying.current.value?.streamUrl == streamUrl) return@LaunchedEffect
c.setPlayingFrom(
streamUrl = streamUrl,
title = title,
uploader = uploader,
thumbnail = thumbnail,
resolved = r,
uploaderUrl = state.detail?.uploaderUrl,
)
}
var playbackError by remember { mutableStateOf<String?>(null) }
DisposableEffect(controller) {
val c = controller
val listener = object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
// Scrub the message — Media3's HttpDataSource exceptions
// include the full signed URL in.message.
val raw = error.message ?: "(no message)"
playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
// Clear NowPlaying so the minibar drops the dead
// session.
NowPlaying.clear()
}
}
c?.addListener(listener)
onDispose { c?.removeListener(listener) }
}
// Track whether the shared controller has actually swapped over to
// THIS video's stream. Until it does (the brief window between
// streamInfo resolving and setPlayingFrom + setMediaItem landing),
// binding PlayerView to the controller would render the PREVIOUS
// video's frame under the new detail page — exactly the "new page,
// old video" bug.
val nowPlaying by NowPlaying.current.collectAsStateWithLifecycle()
val controllerOnThisVideo = nowPlaying?.streamUrl == streamUrl
Box(modifier = modifier, contentAlignment = Alignment.Center) {
when {
state.loading -> CircularProgressIndicator(color = Color.White)
controller == null || state.loading -> CircularProgressIndicator(color = Color.White)
state.error != null -> Text(
"playback error: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
playbackError != null -> Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"playback error: $playbackError",
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = {
// Clear the error AND nudge the LaunchedEffect to
// re-attempt setPlayingFrom.
// without this the screen used to lock on the
// error forever after NowPlaying.clear().
playbackError = null
retryVersion += 1
}) { Text("Retry") }
}
resolved?.isPlayable != true -> Text(
"no playable stream",
color = Color.White,
modifier = Modifier.padding(16.dp),
)
// Stream resolved for THIS URL but the controller hasn't
// actually swapped media items yet — show the thumbnail
// with a spinner. Without this, the PlayerView below would
// bind to the controller and render the OUTGOING video's
// last frame while the new detail page chrome shows the
// new title/description. Bug reported 2026-05-26.
!controllerOnThisVideo -> {
if (!thumbnail.isNullOrBlank()) {
AsyncImage(
model = thumbnail,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
}
CircularProgressIndicator(color = Color.White)
}
else -> {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
player = controller
useController = true
// Same surface-handoff polish as the
// fullscreen PlayerView — hold the last
// frame on dispose so the inline ↔
// fullscreen transition doesn't flash
// black between detach + reattach.
setKeepContentOnPlayerReset(true)
// Don't let the device timeout while the
// inline player is on-screen with the
// user reading the description. Detaches
// automatically when this view goes away.
keepScreenOn = true
}
},
update = { it.player = controller },
onRelease = { it.player = null },
modifier = Modifier.fillMaxSize(),
)
// Top-right fullscreen pill — hops to the fullscreen
// PlayerScreen which has speed/audio-only/share/PiP/background.
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0xCC222222))
.background(OverlayChromeColor)
.clickable(onClick = onFullscreen),
contentAlignment = Alignment.Center,
) {
@ -485,4 +900,3 @@ private fun InlinePlayer(
}
}
}

View file

@ -1,8 +1,17 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* One VM per video URL drives VideoDetail, the fullscreen Player, and
* the inline player on detail (all live in the same activity-scoped VM
* store, so `viewModel()` from each composable returns this instance).
*
* `load(url)` fetches strawcore.streamInfo once, derives both `detail`
* (title, uploader, view count, RYD, related, more-from-channel) and
* `resolved` (the picked stream URLs the player needs), and records the
* video to watch history. Subsequent `load(url)` calls for the same URL
* are a no-op so the spinner only fires on a real navigation change.
*/
package com.sulkta.straw.feature.detail
import androidx.lifecycle.ViewModel
@ -12,155 +21,317 @@ import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.net.RydClient
import com.sulkta.straw.net.RydVotes
import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.net.SponsorBlockClient
import com.sulkta.straw.util.bestThumbnail
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.isAllowedYtUrl
import com.sulkta.straw.util.runCatchingCancellable
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class VideoDetail(
val id: String,
val title: String,
val uploader: String,
val uploaderUrl: String?,
/**
* Uploader's channel avatar (square-ish thumbnail). Populated
* from the same strawcore.channelInfo call that fills
* `moreFromChannel`; null until that call resolves, or when the
* uploaderUrl is missing / fails the allowlist. Renders as a
* small circle next to the channel name on VideoDetail.
*/
val uploaderAvatar: String? = null,
val uploaderSubscriberCount: Long = -1,
val viewCount: Long,
val description: String,
val thumbnail: String?,
val ryd: RydVotes? = null,
val sbSegmentCount: Int = 0,
val related: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(),
/** Other videos from the same channel separate from related (which is YT's
* algo). Anchored to the uploader the user chose; matches the sub-feed ethos. */
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(),
val related: List<StreamItem> = emptyList(),
/**
* Other videos from the same channel separate from `related`
* (which is YT's algo). Anchored to the uploader the user chose;
* matches the sub-feed ethos.
*/
val moreFromChannel: List<StreamItem> = emptyList(),
)
/**
* Stream URLs picked from `streamInfo` for the player. The picker prefers
* DASH (whole-quality + adaptive) HLS combined progressive merged
* video+audio progressive video-only. Carries SB segments for the
* activity-level skip loop.
*/
data class ResolvedPlayback(
val title: String,
val videoUrl: String?,
val audioUrl: String?,
val combinedUrl: String?,
val dashMpdUrl: String?,
val hlsUrl: String?,
val segments: List<SbSegment> = emptyList(),
) {
val isPlayable: Boolean
get() = !combinedUrl.isNullOrBlank() || !videoUrl.isNullOrBlank() ||
!dashMpdUrl.isNullOrBlank() || !hlsUrl.isNullOrBlank()
}
data class VideoDetailUiState(
val loading: Boolean = true,
val detail: VideoDetail? = null,
val resolved: ResolvedPlayback? = null,
val error: String? = null,
// Stored on success for handoff to player. Not in UI.
val streamInfo: StreamInfo? = null,
/** Raw extractor result — kept around for the Download dialog. */
val streamInfo: uniffi.strawcore.StreamInfo? = null,
/**
* Tracks which URL the current `detail`/`resolved` belong to.
* vm is activity-scoped, so a fresh navigation to detail B sees
* the PREVIOUS video's state for one composition frame before
* vm.load(B) clears it. Without this field, the InlinePlayer's
* setPlayingFrom would fire with streamUrl=B but resolved=A's
* playback URLs claiming NowPlaying with B's streamUrl but
* playing A's video under it. audit.
*/
val loadedUrl: String? = null,
)
class VideoDetailViewModel : ViewModel() {
private val _ui = MutableStateFlow(VideoDetailUiState())
val ui: StateFlow<VideoDetailUiState> = _ui.asStateFlow()
private var loadedUrl: String? = null
// Track the active load coroutine so a rapid tap to a different video
// cancels the prior fetch; otherwise a slow-to-finish older load
// overwrites the newer state and the player ends up streaming A while
// the detail UI shows B.
private var inFlight: Job? = null
fun load(streamUrl: String) {
// viewModel() is Activity-scoped, so the same VM is reused across
// navigations. Compare the requested URL with what we last loaded.
if (loadedUrl == streamUrl && _ui.value.detail != null) return
loadedUrl = streamUrl
_ui.value = VideoDetailUiState(loading = true)
viewModelScope.launch {
// viewModel() is activity-scoped, so the same VM is reused across
// navigations. Skip the refetch if the requested URL already has
// a resolved state. Snapshot _ui once so the two reads agree.
val snap = _ui.value
if (snap.loadedUrl == streamUrl && snap.detail != null) return
// Same YT-host gate as ChannelViewModel — covers the case
// where a tap on a poisoned related-card lands here.
// cancel any
// in-flight load on rejection too — otherwise the
// late-arriving prior-job's fence still PASSES (loadedUrl
// wasn't moved) and clobbers the "Unsupported URL" error
// banner.: also set loadedUrl on this
// path so the gate reads coherently for any caller that
// checks _ui.value.loadedUrl on the rejected path.
if (!isAllowedYtUrl(streamUrl)) {
inFlight?.cancel()
inFlight = null
_ui.update {
VideoDetailUiState(
loading = false,
error = "Unsupported URL",
loadedUrl = streamUrl,
)
}
return
}
inFlight?.cancel()
_ui.update { VideoDetailUiState(loading = true, loadedUrl = streamUrl) }
inFlight = viewModelScope.launch {
try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
// strawcore.streamInfo is suspend on tokio; no Dispatchers.IO wrap.
val info = uniffi.strawcore.streamInfo(streamUrl)
val videoId = info.id
val thumb = bestThumbnail(info.thumbnails)
val title = info.name ?: "(no title)"
val uploader = info.uploaderName ?: ""
val thumb = info.thumbnail
val title = info.title.ifBlank { "(no title)" }
val uploader = info.uploader
runCatching {
History.get().recordWatch(
WatchHistoryItem(
url = streamUrl,
videoId = videoId,
title = title,
uploader = uploader,
thumbnail = thumb,
watchedAt = 0L,
),
// Move SP write off the main coroutine — recordWatch
// JSON-encodes the watch list (up to 50 entries) +
// sp.edit.apply. Small but synchronous;
// audit Q9. Only record when the resolved URL passes
// the YT allowlist — otherwise extractor-emitted
// non-YT URLs (poisoned related/moreFromChannel) end
// up in Recent Watches and survive process death.
if (isAllowedYtUrl(streamUrl)) {
withContext(Dispatchers.IO) {
runCatchingCancellable {
History.get().recordWatch(
WatchHistoryItem(
url = streamUrl,
videoId = videoId,
title = title,
uploader = uploader,
thumbnail = thumb,
watchedAt = 0L,
),
)
}
}
}
// RYD + SponsorBlock in parallel — both are independent
// network round-trips that block the detail UI. Running
// them sequentially via two withContext blocks left the
// slower one fully serialized behind the faster one
// (~200-500ms wasted per video open). async{}.await()
// on Dispatchers.IO closes that gap.
val sbCats = Settings.get().sbCategories.value.map { it.key }
val (ryd, segments) = coroutineScope {
val rydDeferred = async(Dispatchers.IO) {
runCatchingCancellable { RydClient.fetch(videoId) }.getOrNull()
}
val sbDeferred = async(Dispatchers.IO) {
if (sbCats.isEmpty()) emptyList()
else runCatchingCancellable {
SponsorBlockClient.fetch(videoId, sbCats)
}.getOrDefault(emptyList())
}
rydDeferred.await() to sbDeferred.await()
}
val related = info.related.map { r ->
StreamItem(
url = r.url,
title = r.title.ifBlank { "(no title)" },
uploader = r.uploader,
uploaderUrl = r.uploaderUrl,
thumbnail = r.thumbnail,
durationSeconds = r.durationSeconds,
viewCount = r.viewCount,
uploadDateRelative = r.uploadDateRelative,
)
}
val ryd = withContext(Dispatchers.IO) {
runCatching { RydClient.fetch(videoId) }.getOrNull()
}
val sbCats = Settings.get().sbCategories.value.map { it.key }
val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) {
runCatching { SponsorBlockClient.fetch(videoId, sbCats).size }.getOrDefault(0)
}
val related = info.relatedItems
?.filterIsInstance<StreamInfoItem>()
?.map { it ->
com.sulkta.straw.feature.search.StreamItem(
url = it.url,
title = it.name ?: "(no title)",
uploader = it.uploaderName ?: "",
uploaderUrl = it.uploaderUrl,
thumbnail = bestThumbnail(it.thumbnails),
durationSeconds = it.duration,
viewCount = it.viewCount,
)
} ?: emptyList()
// More from this channel — anchored to the uploader the user
// already chose. Best-effort: empty if the fetch fails so the
// detail screen still renders. Filters out the current video.
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> =
if (info.uploaderUrl.isNullOrBlank()) emptyList()
else withContext(Dispatchers.IO) {
runCatching {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val ch = ChannelInfo.getInfo(service, info.uploaderUrl)
val videosTab = ch.tabs.firstOrNull {
it.contentFilters.contains(ChannelTabs.VIDEOS)
} ?: ch.tabs.firstOrNull()
if (videosTab == null) emptyList()
else ChannelTabInfo.getInfo(service, videosTab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
// More from this channel via strawcore.channelInfo — one
// Rust round-trip returns the channel's Videos tab pre-mapped.
// Gate the auto-fetch behind the same YT-host allowlist
// we apply to imports: a poisoned uploaderUrl from the
// extractor would otherwise trigger an arbitrary-host
// network call.
//
// validate once and persist the
// SAFE value into VideoDetail.uploaderUrl so downstream
// consumers (NowPlaying → PlaybackService autoplay,
// queue, etc.) inherit the validated string instead
// of the raw extractor value.
val rawUploaderUrl = info.uploaderUrl
val uploaderUrl = if (!rawUploaderUrl.isNullOrBlank() && isAllowedYtUrl(rawUploaderUrl)) {
rawUploaderUrl
} else null
data class ChannelExtras(
val avatar: String?,
val subscriberCount: Long,
val videos: List<StreamItem>,
)
val channelExtras: ChannelExtras =
if (uploaderUrl == null) {
ChannelExtras(null, -1, emptyList())
} else runCatchingCancellable {
val ch = uniffi.strawcore.channelInfo(uploaderUrl)
// Opportunistic avatar refresh: if the user is
// subscribed and our stored avatar is stale or
// missing, push the fresh one back to the store
// so the subs feed picks it up too.
//
// Validate the scheme before persisting — the
// extractor surfaces the URL string verbatim
// and a poisoned channel page could ship
// `data:image/svg+xml,<svg>...<script>` or
// `javascript:`.
val fresh = ch.avatar
val safeFresh = if (!fresh.isNullOrBlank() &&
(fresh.startsWith("https://") || fresh.startsWith("http://"))) {
fresh
} else null
if (safeFresh != null) {
runCatchingCancellable {
com.sulkta.straw.data.Subscriptions
.get().updateAvatar(uploaderUrl, safeFresh)
}
}
ChannelExtras(
avatar = safeFresh,
subscriberCount = ch.subscriberCount,
videos = ch.videos
.filter { it.url != streamUrl }
.take(20)
.map { si ->
com.sulkta.straw.feature.search.StreamItem(
url = si.url,
title = si.name ?: "(no title)",
uploader = si.uploaderName ?: uploader,
uploaderUrl = si.uploaderUrl ?: info.uploaderUrl,
thumbnail = bestThumbnail(si.thumbnails),
durationSeconds = si.duration,
viewCount = si.viewCount,
.map { v ->
StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader.ifBlank { uploader },
uploaderUrl = v.uploaderUrl ?: uploaderUrl,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
uploadDateRelative = v.uploadDateRelative,
)
}
}.getOrDefault(emptyList())
}
},
)
}.getOrDefault(ChannelExtras(null, -1, emptyList()))
val moreFromChannel = channelExtras.videos
_ui.value = VideoDetailUiState(
loading = false,
detail = VideoDetail(
id = videoId,
title = title,
uploader = uploader,
uploaderUrl = info.uploaderUrl,
viewCount = info.viewCount,
description = info.description?.content ?: "",
thumbnail = thumb,
ryd = ryd,
sbSegmentCount = sbCount,
related = related,
moreFromChannel = moreFromChannel,
),
streamInfo = info,
)
val resolved = resolvePlayback(info, segments)
// Fence the terminal write against late-arriving older
// loads: if a subsequent load(B) cancelled this one but
// we resolved past the suspension point, drop our
// result rather than clobber B's state.
// : single source of
// truth — read loadedUrl from _ui rather than a
// shadowing field.
if (_ui.value.loadedUrl != streamUrl) return@launch
_ui.update {
VideoDetailUiState(
loading = false,
detail = VideoDetail(
id = videoId,
title = title,
uploader = uploader,
// Use the allowlist-validated value, not
// the raw extractor field.
uploaderUrl = uploaderUrl,
uploaderAvatar = channelExtras.avatar,
uploaderSubscriberCount = channelExtras.subscriberCount,
viewCount = info.viewCount,
description = info.description,
thumbnail = thumb,
ryd = ryd,
sbSegmentCount = segments.size,
related = related,
moreFromChannel = moreFromChannel,
),
resolved = resolved,
streamInfo = info,
loadedUrl = streamUrl,
)
}
} catch (t: Throwable) {
_ui.value = VideoDetailUiState(
loading = false,
error = t.message ?: t.javaClass.simpleName,
)
if (t is CancellationException) throw t
if (_ui.value.loadedUrl != streamUrl) return@launch
_ui.update {
VideoDetailUiState(
loading = false,
error = com.sulkta.straw.util.LogDump.scrubLine(
t.message ?: t.javaClass.simpleName,
),
loadedUrl = streamUrl,
)
}
}
}
}
private fun resolvePlayback(
info: uniffi.strawcore.StreamInfo,
segments: List<SbSegment>,
): ResolvedPlayback = resolveStreamPlayback(info, segments)
}

View file

@ -2,18 +2,18 @@
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase R: minimal download via Android's DownloadManager. Saves to the
* Minimal download via Android's DownloadManager. Saves to the
* app-private external files dir so we don't need WRITE_EXTERNAL_STORAGE
* on older Android. The user can pull files out via a file manager
* (under Android/data/com.sulkta.straw.debug/files/...).
*
* Audit fixes (2026-05-24 pass #2):
* HIGH-4: scheme + host validation on the URL before handing it to
* DownloadManager extractor output is not trusted root-of-truth.
* HIGH-5: harder filename sanitization control chars, bidi overrides,
* leading dots, trailing whitespace.
* MED-6: catch IllegalArgumentException from enqueue so a malformed URI
* doesn't crash the click handler.
* Hardening:
* - scheme + host validation on the URL before enqueueing (extractor
* output is not trusted root-of-truth)
* - filename sanitization for control chars, bidi overrides, leading
* dots, and trailing whitespace
* - catches IllegalArgumentException from enqueue so a malformed URI
* doesn't crash the click handler
*/
package com.sulkta.straw.feature.download
@ -51,11 +51,27 @@ object Downloader {
val filename = "$safeTitle${kind.ext}"
val dm = ctx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
// SECURITY: pre-signed googlevideo URLs leak to anything reading
// DownloadManager state (system notification stack, downloads UI,
// apps with ACCESS_DOWNLOAD_MANAGER). We can't hide the URL from
// DM itself without re-implementing the download, but we can hide
// it from every surface DM forwards to:
// setNotificationVisibility(HIDDEN) — no system notification
// surfaces the URL via tap-to-open / accessibility scrapers.
// setVisibleInDownloadsUi(false) — the Downloads system app
// won't list this entry, so a user opening Files / Downloads
// can't long-press → details → see the URL.
// Our own DownloadsScreen reads progress out of DM via the ID
// returned below, so user-facing UX is unaffected.
val req = runCatching {
DownloadManager.Request(Uri.parse(url))
.setTitle(title)
// Sanitized title — bidi-overrides and control chars
// in extractor output would otherwise render in
// DownloadsScreen's row title.
.setTitle(safeTitle)
.setDescription("Straw — ${kind.name.lowercase()}")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
.setVisibleInDownloadsUi(false)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
.setDestinationInExternalFilesDir(
@ -88,8 +104,9 @@ object Downloader {
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
if (!uri.scheme.equals("https", ignoreCase = true)) return false
val host = uri.host?.lowercase() ?: return false
return host.endsWith(".googlevideo.com") ||
host.endsWith(".youtube.com") ||
host == "youtube.com"
// strawcore returns video/audio stream URLs from googlevideo CDN
// exclusively — youtube.com URLs aren't direct streams and have
// no business going to DownloadManager.
return host.endsWith(".googlevideo.com")
}
}

View file

@ -0,0 +1,278 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Downloads tab lists everything Phase R's Downloader handed off to
* Android's DownloadManager. Reads live from DownloadManager.query()
* keyed by package owner, so we naturally show only this app's queue.
*
* Row shows: title, kind (audio / video), state (running / completed /
* failed), and progress / size. Tap a completed row ACTION_VIEW
* intent to whatever player the user has registered. × removes the
* entry from the queue (and the file, per DownloadManager.remove
* semantics).
*/
package com.sulkta.straw.feature.download
import android.app.DownloadManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.sulkta.straw.util.rememberBottomContentPadding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
data class DownloadRow(
val id: Long,
val title: String,
val localUri: String?,
val mediaType: String?,
val status: Int,
val reason: Int,
val bytesSoFar: Long,
val totalBytes: Long,
) {
val progressFraction: Float?
get() = if (totalBytes > 0) (bytesSoFar.toFloat() / totalBytes).coerceIn(0f, 1f) else null
val statusLabel: String
get() = when (status) {
DownloadManager.STATUS_RUNNING -> "downloading"
DownloadManager.STATUS_PENDING -> "pending"
DownloadManager.STATUS_PAUSED -> "paused"
DownloadManager.STATUS_SUCCESSFUL -> "done"
DownloadManager.STATUS_FAILED -> "failed"
else -> "unknown"
}
}
@Composable
fun DownloadsScreen() {
val context = LocalContext.current
var rows by remember { mutableStateOf<List<DownloadRow>>(emptyList()) }
// DownloadManager doesn't broadcast progress, so we poll while the
// screen is visible. Fast cadence (1s) when something is actively
// running, slow cadence (5s) when everything is settled — no
// animations to update.
LaunchedEffect(Unit) {
while (true) {
// DownloadManager.query() is a ContentResolver IPC + a
// SQLite cursor walk — disk I/O on the main coroutine
// visibly stutters on devices with hundreds of historical
// downloads.
val fresh = withContext(Dispatchers.IO) { queryDownloads(context) }
rows = fresh
val active = fresh.any {
it.status == DownloadManager.STATUS_RUNNING ||
it.status == DownloadManager.STATUS_PENDING
}
delay(if (active) 1000 else 5000)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
Text(
"Downloads",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"${rows.size} item${if (rows.size == 1) "" else "s"} · saved to app private storage",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
if (rows.isEmpty()) {
Text(
"Nothing here yet. Tap Download on any video.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn(contentPadding = rememberBottomContentPadding()) {
items(rows, key = { it.id }) { row ->
DownloadRowView(row, context, onRemove = {
runCatching {
(context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager)
.remove(row.id)
}
rows = rows.filterNot { it.id == row.id }
})
HorizontalDivider()
}
}
}
}
}
@Composable
private fun DownloadRowView(
row: DownloadRow,
context: Context,
onRemove: () -> Unit,
) {
val openable = row.status == DownloadManager.STATUS_SUCCESSFUL && !row.localUri.isNullOrBlank()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = openable) {
row.localUri?.let { uri ->
// DownloadManager returns a file:// URI for the
// setDestinationInExternalFilesDir target. Passing
// that across an app boundary throws
// FileUriExposedException on every API >= 24 since
// minSdk 24. Route through FileProvider so the
// receiver gets a grantable content:// URI instead.
val shareUri = runCatching {
val src = Uri.parse(uri)
val path = src.path
if (src.scheme == "file" && path != null) {
androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
java.io.File(path),
)
} else {
src
}
}.getOrNull() ?: return@let
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(shareUri, row.mediaType ?: "*/*")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
runCatching { context.startActivity(intent) }
}
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(width = 56.dp, height = 56.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Text(if (row.mediaType?.startsWith("audio") == true) "🎵" else "🎬")
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
row.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
buildString {
append(row.statusLabel)
if (row.totalBytes > 0) {
append(" · ")
append(formatBytes(row.bytesSoFar))
append(" / ")
append(formatBytes(row.totalBytes))
} else if (row.bytesSoFar > 0) {
append(" · ")
append(formatBytes(row.bytesSoFar))
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
row.progressFraction?.takeIf { row.status != DownloadManager.STATUS_SUCCESSFUL }
?.let { p ->
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { p },
modifier = Modifier.fillMaxWidth(),
)
}
}
TextButton(onClick = onRemove) { Text("×") }
}
}
private fun queryDownloads(context: Context): List<DownloadRow> {
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
?: return emptyList()
val query = DownloadManager.Query()
val out = mutableListOf<DownloadRow>()
runCatching { dm.query(query) }.getOrNull()?.use { c ->
val idIdx = c.getColumnIndex(DownloadManager.COLUMN_ID)
val titleIdx = c.getColumnIndex(DownloadManager.COLUMN_TITLE)
val uriIdx = c.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
val mimeIdx = c.getColumnIndex(DownloadManager.COLUMN_MEDIA_TYPE)
val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON)
val soFarIdx = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val totalIdx = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
while (c.moveToNext()) {
out += DownloadRow(
id = c.getLong(idIdx),
title = c.getString(titleIdx) ?: "(no title)",
localUri = c.getString(uriIdx),
mediaType = c.getString(mimeIdx),
status = c.getInt(statusIdx),
reason = c.getInt(reasonIdx),
bytesSoFar = c.getLong(soFarIdx),
totalBytes = c.getLong(totalIdx),
)
}
}
return out.sortedByDescending { it.id }
}
private fun formatBytes(b: Long): String = when {
b < 1024 -> "$b B"
b < 1024L * 1024 -> "${b / 1024} KB"
b < 1024L * 1024 * 1024 -> "%.1f MB".format(b.toDouble() / (1024 * 1024))
else -> "%.2f GB".format(b.toDouble() / (1024L * 1024 * 1024))
}

View file

@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Schedules FeedRefreshWorker via WorkManager based on Settings.
* Called from StrawApp.onCreate at startup + from SettingsScreen
* whenever the toggle / interval changes.
*/
package com.sulkta.straw.feature.feed
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.sulkta.straw.data.BgFeedRefreshInterval
import com.sulkta.straw.data.Settings
import java.util.concurrent.TimeUnit
private const val WORK_NAME = "straw-feed-refresh"
object FeedRefreshScheduler {
fun applyFromSettings(context: Context) {
val s = Settings.get()
val wm = WorkManager.getInstance(context.applicationContext)
if (!s.bgFeedRefreshEnabled.value) {
wm.cancelUniqueWork(WORK_NAME)
return
}
// WorkManager 15-minute periodic floor — see UpdateScheduler.
val request = PeriodicWorkRequestBuilder<FeedRefreshWorker>(
s.bgFeedRefreshInterval.value.minutes.coerceAtLeast(15L),
TimeUnit.MINUTES,
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
).build()
wm.enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
}
}
private val BgFeedRefreshInterval.minutes: Long
get() = when (this) {
BgFeedRefreshInterval.M30 -> 30
BgFeedRefreshInterval.H1 -> 60
BgFeedRefreshInterval.H6 -> 6 * 60
}

View file

@ -0,0 +1,104 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Background subscription-feed refresh. Periodically calls
* uniffi.strawcore.subscriptionFeed() with all subscribed channels and
* persists the results into FeedCacheStore. Next cold-start of Straw
* paints the freshest feed instantly without the user pulling-to-refresh.
*
* The the RSS swap dropped per-channel fetch time from ~500ms to
* ~50-150ms, so a 50-sub refresh now costs ~1-2s total small enough to
* run quietly in the background on the user's chosen cadence.
*
* Disabled by default (opt-in via Settings). Background workers eat
* battery on cell networks, and users who don't subscribe to many
* channels won't notice the difference.
*/
package com.sulkta.straw.feature.feed
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.FeedCacheEntry
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.strawLogI
import com.sulkta.straw.util.strawLogW
class FeedRefreshWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
if (!Settings.get().bgFeedRefreshEnabled.value) return Result.success()
val subs = Subscriptions.get().subs.value
if (subs.isEmpty()) return Result.success()
strawLogI("FeedRefresh", "background tick: ${subs.size} channels")
// One bulk call via the Rust subscriptionFeed fan-out. Returns
// a flat list; we group by uploaderUrl to rebuild the per-
// channel cache shape FeedCacheStore expects.
//
// Distinguish transient failures (network down, timeout) from
// parse failures. The former wants Result.retry() so
// WorkManager re-attempts within the current window with
// exponential backoff; without this, a 30-second offline blip
// eats a full 6-hour refresh cycle.:
// earlier `IOException` catch was dead code — UniFFI throws
// `uniffi.strawcore.StrawcoreException.Network` for transport
// errors, which does NOT extend IOException.
val flat = try {
uniffi.strawcore.subscriptionFeed(subs.map { it.url })
} catch (e: uniffi.strawcore.StrawcoreException.Network) {
strawLogW("FeedRefresh") { "transient network failure, retrying: ${e.message}" }
return Result.retry()
} catch (e: uniffi.strawcore.StrawcoreException.RequiresLogin) {
// reCAPTCHA challenges clear on their own minutes-to-hours
// later. Treating these as permanent eats a full refresh
// cycle the same way the pre-fix IOException catch did.
strawLogW("FeedRefresh") { "YT challenge, retrying: ${e.message}" }
return Result.retry()
} catch (e: Throwable) {
strawLogW("FeedRefresh") { "non-transient failure, giving up this cycle: ${e.message}" }
return Result.success()
}
val now = System.currentTimeMillis()
val grouped: Map<String, FeedCacheEntry> = flat
.groupBy { it.uploaderUrl.orEmpty() }
.filterKeys { it.isNotBlank() }
.mapValues { (chUrl, items) ->
FeedCacheEntry(
fetchedAt = now,
items = items.map { v ->
StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader,
uploaderUrl = v.uploaderUrl ?: chUrl,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
uploadDateRelative = v.uploadDateRelative,
)
},
)
}
if (grouped.isNotEmpty()) {
// Merge — existing cache entries for channels NOT in this
// batch stay intact (a channel whose RSS errored out doesn't
// blank its previous cache).
val before = FeedCache.get().load()
val merged = before.toMutableMap()
grouped.forEach { (k, v) -> merged[k] = v }
FeedCache.get().save(merged)
strawLogI("FeedRefresh", "wrote ${grouped.size} channels to FeedCache")
}
return Result.success()
}
}

View file

@ -2,31 +2,39 @@
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase Q: aggregate latest videos across all subscribed channels into a
* single feed. Fans out per-channel ChannelInfo + ChannelTabs.VIDEOS
* fetches in parallel, merges by view count desc, caps at 200 items.
* Aggregate latest videos across all subscribed channels into a single
* feed. Per-channel fan-out with independent TTL caches. Bigger per
* channel limit so the feed actually feels "show me everything new",
* sorted by parsed relative upload date so the merged list reads
* newest-first across channels.
*
* Audit fixes (2026-05-24 pass #2):
* HIGH-6: cancel any prior in-flight refresh when a new one starts, cap
* concurrency with a Semaphore, time-bound each per-channel fetch so
* one hung channel can't stall the whole feed.
* MED-7: use `update { }` for atomic UI-state writes (matches the
* convention applied to the data stores in audit pass #1).
* Also opportunistically refreshes a channel's avatar in
* SubscriptionsStore strawcore can occasionally return null on first
* subscribe (the channel header layout varies); a subsequent feed fetch
* will fill it in automatically.
*/
package com.sulkta.straw.feature.feed
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.Enrichment
import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.FeedCacheEntry
import com.sulkta.straw.data.FeedEnrichment
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.bestThumbnail
import com.sulkta.straw.util.runCatchingCancellable
import com.sulkta.straw.util.strawLogW
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -36,12 +44,7 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import java.util.concurrent.ConcurrentHashMap
data class SubscriptionFeedUiState(
val loading: Boolean = false,
@ -51,101 +54,441 @@ data class SubscriptionFeedUiState(
)
class SubscriptionFeedViewModel : ViewModel() {
private val _ui = MutableStateFlow(SubscriptionFeedUiState())
// Seed loading=true: the init block always either hydrates from
// disk or fires a refresh, so the user should see the spinner
// (or cached content under it) rather than a one-frame flash of
// empty.
private val _ui = MutableStateFlow(SubscriptionFeedUiState(loading = true))
val ui: StateFlow<SubscriptionFeedUiState> = _ui.asStateFlow()
/** Cache feed for 10 min to avoid hammering YT on tab re-entry. */
private val cacheTtlMs = 10L * 60 * 1000
/**
* Per-channel cache: each entry refreshes independently. Hydrated
* from disk on init via FeedCacheStore so cold app starts can show
* the last successful fetch instantly. ConcurrentHashMap because
* fetchChannelInto writes concurrently from the per-channel
* coroutines; mergeFromCache and refreshIfStale read.
*/
private val channelCache = ConcurrentHashMap<String, FeedCacheEntry>()
/** Per-channel fetch timeout — slowest channel can't stall the whole batch. */
private val perChannelTimeoutMs = 15_000L
/** Per-channel TTL — Refresh just re-fetches stale entries. */
private val perChannelTtlMs = 30L * 60 * 1000
/** Cap parallel network fetches even with 100+ subs. */
private val parallelism = 8
/** Live refresh job, so spam-tapping Refresh doesn't fan out racing fetches. */
private var inFlight: Job? = null
fun refreshIfStale() {
val now = System.currentTimeMillis()
if (_ui.value.items.isNotEmpty() && now - _ui.value.lastFetchedAt < cacheTtlMs) return
refresh()
}
fun refresh() {
val channels = Subscriptions.get().subs.value
if (channels.isEmpty()) {
_ui.update { SubscriptionFeedUiState(loading = false, items = emptyList()) }
return
}
inFlight?.cancel()
_ui.update { it.copy(loading = true, error = null) }
inFlight = viewModelScope.launch {
try {
val items = withContext(Dispatchers.IO) {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val perChannelMax = 5
val gate = Semaphore(parallelism)
coroutineScope {
val deferreds = channels.map { ch ->
async {
gate.withPermit {
withTimeoutOrNull(perChannelTimeoutMs) {
runCatching {
val info = ChannelInfo.getInfo(service, ch.url)
val tab = info.tabs.firstOrNull {
it.contentFilters.contains(ChannelTabs.VIDEOS)
} ?: info.tabs.firstOrNull()
?: return@runCatching emptyList<StreamItem>()
ChannelTabInfo.getInfo(service, tab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
.take(perChannelMax)
.map { si ->
StreamItem(
url = si.url,
title = si.name ?: "(no title)",
uploader = si.uploaderName ?: ch.name,
uploaderUrl = si.uploaderUrl ?: ch.url,
thumbnail = bestThumbnail(si.thumbnails),
durationSeconds = si.duration,
viewCount = si.viewCount,
)
}
}.onFailure {
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
}.getOrDefault(emptyList())
} ?: run {
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
emptyList()
}
}
}
}
deferreds.awaitAll()
}
.flatten()
// No reliable upload-timestamp from extractor's StreamInfoItem
// in all cases — sort by view count desc as a soft proxy for
// recency-popularity within the recent window.
.sortedByDescending { it.viewCount }
.take(200)
}
_ui.update {
SubscriptionFeedUiState(
loading = false,
items = items,
lastFetchedAt = System.currentTimeMillis(),
)
}
} catch (t: Throwable) {
init {
// Hydrate from disk and immediately render the cached items so
// the Subs tab paints before the network round-trip resolves.
// previously this ran synchronously on the
// main thread at VM construction, blocking the first compose
// pass on a ~225 KB Json.decodeFromString.
viewModelScope.launch {
if (!Settings.get().cacheEnabled.value) return@launch
val saved = withContext(Dispatchers.IO) { FeedCache.get().load() }
if (saved.isEmpty()) return@launch
// putIfAbsent (not putAll) — refresh() may have started
// populating fresh entries during our IO suspension; we
// must not overwrite those with disk-stale values.
saved.forEach { (url, entry) -> channelCache.putIfAbsent(url, entry) }
val channels = Subscriptions.get().subs.value
if (channels.isNotEmpty()) {
pruneCacheToSubs(channels)
val savedTs = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L
// Compute the merge off-Main first.
// FlatMap + regex + sort on hydration was
// running on Main and could add ~10-20 ms to cold
// start on a slow phone.
val hydrated = withContext(Dispatchers.Default) { mergeFromCache(channels) }
// _ui.update so a concurrent refresh()'s state write
// doesn't race with this copy.
// Only advance lastFetchedAt — never regress.
_ui.update {
it.copy(
loading = false,
error = t.message ?: t.javaClass.simpleName,
items = hydrated,
lastFetchedAt = maxOf(it.lastFetchedAt, savedTs),
)
}
}
}
}
/**
* Per-channel fetch timeout. 10s instead of 15s a channel that
* hasn't responded in 10s is likely a transient network hiccup or a
* dead channel handle; better to drop it from the batch and ride
* the disk-cache stale value than block the whole feed.
*/
private val perChannelTimeoutMs = 10_000L
/**
* Parallel network fetches. Cranked from 12 50 previously alongside
* the RSS-feed swap. Each fetch is now a ~5-15KB Atom XML payload
* instead of a ~150KB InnerTube channel-page scrape Tokio's
* `buffer_unordered` inside `subscription_feed()` handles >50
* concurrent without breaking a sweat, and the Kotlin gate just
* keeps the launch fan-out bounded so we don't blow the file-
* descriptor budget on a 200-sub user.
*/
private val parallelism = 50
/**
* Videos pulled per channel. RSS returns up to 15 most-recent
* videos per channel that's the upstream cap, so 15 is our
* effective ceiling here. We sort + interleave across all subs
* client-side after the fan-out completes.
*/
private val perChannelMax = 15
/** Live refresh job, so spam-tapping Refresh doesn't fan out racing fetches. */
private var inFlight: Job? = null
/**
* The background enrichment job runs on StrawApp.globalScope so it
* outlives the VM's viewModelScope but a refresh-cancel must
* still kill the *previous* enrichment so we don't pile up
* overlapping fan-outs (8-wide × N overlapping refreshes blows the
* concurrency budget). Tracked here, cancelled in the same places
* `inFlight` is./3/8.
*/
private var enrichJob: Job? = null
fun refreshIfStale() {
// Skip if a refresh is already in flight.:
// SubsPane's LaunchedEffect(subs) re-fires every time
// Subscriptions.updateAvatar emits a fresh list reference (which
// fetchChannelInto does opportunistically per channel). Without
// this gate, each per-channel avatar backfill cancels the
// parallel-12 batch and turns the refresh into N sequential
// single-channel fetches.
if (inFlight?.isActive == true) return
val now = System.currentTimeMillis()
val anyStale = Subscriptions.get().subs.value.any { ch ->
val entry = channelCache[ch.url]
entry == null || now - entry.fetchedAt >= perChannelTtlMs
}
if (anyStale || _ui.value.items.isEmpty()) refreshInternal(force = false)
}
fun refresh() = refreshInternal(force = true)
private fun refreshInternal(force: Boolean) {
// Cancel any in-flight refresh at the TOP — including before
// the empty-channels branch. Without this, a refresh that
// ran on a non-empty sub set could still be writing to
// channelCache when the user unsubscribes from the last
// channel; we'd clear() then immediately repopulate with
// phantom entries when the prior fetchChannelInto resolved.
// Also kill any in-flight
// enrichment fan-out so we don't end up with N overlapping
// enrich jobs piling up under spam-refresh
inFlight?.cancel()
enrichJob?.cancel()
val channels = Subscriptions.get().subs.value
if (channels.isEmpty()) {
_ui.update { it.copy(loading = false, items = emptyList(), error = null) }
channelCache.clear()
viewModelScope.launch(Dispatchers.IO) {
runCatching { FeedCache.get().clear() }
}
return
}
_ui.update { it.copy(loading = true, error = null) }
inFlight = viewModelScope.launch {
try {
val gate = Semaphore(parallelism)
val now = System.currentTimeMillis()
coroutineScope {
// force=true (user tapped Refresh): fan out across
// every subscribed channel. force=false (the auto
// refreshIfStale path): only the stale entries.
// — previously refresh also
// filtered to stale-only, so a user-initiated tap
// 5min after the last refresh was a silent no-op.
channels
.filter { ch ->
if (force) return@filter true
val entry = channelCache[ch.url]
entry == null || now - entry.fetchedAt >= perChannelTtlMs
}
.map { ch -> async { gate.withPermit { fetchChannelInto(ch) } } }
.awaitAll()
}
pruneCacheToSubs(channels)
// Move flatMap + per-item regex + sort off Main —
// viewModelScope.launch runs on Main by default and
// mergeFromCache is non-trivial on a 500-item merge.
// ensureActive AFTER the
// withContext hop is: a
// synchronous Default body doesn't observe
// cancellation until the next suspension; without
// this check, a cancel that landed mid-merge would
// still let the terminal _ui.update fire and clobber
// a fresher state.
val freshItems = withContext(Dispatchers.Default) { mergeFromCache(channels) }
coroutineContext.ensureActive()
_ui.update {
SubscriptionFeedUiState(
loading = false,
items = freshItems,
lastFetchedAt = System.currentTimeMillis(),
)
}
// hybrid backfill. RSS-fed items have
// viewCount=0 + durationSeconds=0; kick a bounded
// background job that calls enrichFeedItem for the
// top items and pumps a fresh _ui emit when done.
// Pass the channels snapshot so the enrich job's
// terminal mergeFromCache uses what was current at
// job start, not whatever the user's subs are by
// the time enrichment finishes ~2s later.
enrichVisibleItems(freshItems, channels)
// Persist what we just freshened. Off the main thread —
// JSON encode on 30 subs * 30 items is small but not
// free, and SharedPreferences.apply is async anyway.
// Skipped entirely when the user has disabled caching.
if (Settings.get().cacheEnabled.value) {
withContext(Dispatchers.IO) {
runCatching { FeedCache.get().save(channelCache.toMap()) }
}
}
} catch (t: Throwable) {
// Re-throw cancellation so spam-tapping Refresh (or
// toggling cache OFF→ON during a refresh) doesn't
// surface a "refresh failed: StandaloneCoroutineCancelled"
// banner above the cached items.
if (t is CancellationException) throw t
_ui.update {
it.copy(
loading = false,
error = com.sulkta.straw.util.LogDump.scrubLine(
t.message ?: t.javaClass.simpleName,
),
)
}
}
}
}
private suspend fun fetchChannelInto(ch: ChannelRef) {
// swapped uniffi.strawcore.channelInfo (~500ms each,
// full InnerTube page scrape with JS eval) for the RSS feed
// (~50-150ms each, tiny Atom XML). Same fan-out architecture,
// ~5-10× faster. Avatar backfill is skipped on this path —
// RSS doesn't carry avatars; the existing avatar lazy-loads
// when the user taps into the channel screen.
val outcome = withTimeoutOrNull(perChannelTimeoutMs) {
runCatchingCancellable {
val videos = uniffi.strawcore.channelFeedRss(ch.url)
videos.take(perChannelMax).map { v ->
StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader.ifBlank { ch.name },
uploaderUrl = v.uploaderUrl ?: ch.url,
thumbnail = v.thumbnail,
// RSS doesn't carry duration or view count.
// These backfill on tap-through when the user
// opens the detail screen and we resolve full
// streamInfo. 0 means "unknown" — the row
// renderer hides the badges when 0.
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
uploadDateRelative = v.uploadDateRelative,
)
}
}.onFailure {
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
}.getOrDefault(emptyList())
} ?: run {
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
emptyList()
}
// Only update the cache on a successful fetch. A timeout/error
// leaves any prior cache entry intact, so a glitchy channel
// doesn't blank your feed for that channel.
if (outcome.isNotEmpty()) {
channelCache[ch.url] = FeedCacheEntry(System.currentTimeMillis(), outcome)
}
}
private fun pruneCacheToSubs(channels: List<ChannelRef>) {
val subUrls = channels.map { it.url }.toSet()
channelCache.keys.toList().forEach { if (it !in subUrls) channelCache.remove(it) }
}
private fun mergeFromCache(channels: List<ChannelRef>): List<StreamItem> {
// Pure read. Caller is responsible for calling pruneCacheToSubs
// beforehand when channel-set changes matter — split here
// because the prior version's "merge" name hid a side-effecting
// prune that violated single-responsibility (.
//
// Pre-compute recencyScore once per item audit
// MED-Q15: sortedWith's comparator was invoking the regex
// twice per pair, so ~1800 regex matches on a 900-item merge.
//
// overlay FeedEnrichment data on each item so RSS-fed
// rows (viewCount=0, durationSeconds=0) get backfilled with
// metadata fetched by the background enrichment job below.
// Pure read of the enrichment store; the enrichment write
// path triggers a fresh _ui emit.
val enrichments = FeedEnrichment.get().entries.value
return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() }
.map { it.withEnrichment(enrichments) }
.map { it to it.recencyScore() }
.sortedWith(
compareByDescending<Pair<StreamItem, Long>> { it.second }
.thenByDescending { it.first.viewCount },
)
.take(500)
.map { it.first }
}
/**
* Background enrichment: pulls viewCount + durationSeconds for the
* top-N freshly-merged items via the lightweight
* uniffi.strawcore.enrichFeedItem endpoint. Bounded parallel
* (8-wide) each call is ~500ms full streamInfo, so 30 items
* complete in ~2s. Skipped per-item when FeedEnrichment already
* has a fresh hit (TTL controlled by Settings.cacheTtl).
*
* Runs on viewModelScope: outliving the VM
* would mean a destroyed _ui can still receive a stale emit (and
* mergeFromCache reads a now-cleared channelCache). The next
* VM instance does its own enrichment on next refresh; nothing
* is lost by not finishing the prior one. Tracked in enrichJob so
* refresh + clearInMemoryCache can cancel it.
*/
private fun enrichVisibleItems(items: List<StreamItem>, channelsSnapshot: List<ChannelRef>) {
val take = items.take(ENRICH_HEAD_COUNT)
.filter { it.viewCount <= 0L && it.durationSeconds <= 0L }
if (take.isEmpty()) return
enrichJob?.cancel()
enrichJob = viewModelScope.launch {
val gate = Semaphore(ENRICH_PARALLELISM)
coroutineScope {
take.map { item ->
async {
gate.withPermit {
val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(item.url)
?: return@withPermit
// Defense in depth: enrichFeedItem calls
// strawcore.stream_info which expects a
// canonical YT URL. A poisoned cached
// item.url shouldn't be able to reach the
// extractor either. All uniffi.strawcore.*
// sites that take a user-influenced URL get
// the same gate.
if (!com.sulkta.straw.util.isAllowedYtUrl(item.url)) return@withPermit
if (FeedEnrichment.get().get(videoId) != null) return@withPermit
val md = runCatchingCancellable {
withContext(Dispatchers.IO) {
uniffi.strawcore.enrichFeedItem(item.url)
}
}.getOrNull() ?: return@withPermit
FeedEnrichment.get().put(
videoId,
md.viewCount,
md.durationSeconds,
)
}
}
}.awaitAll()
}
// Compute the merge off-Main — flatMap + per-item regex
// + sort over up to 500 items is too much for the UI
// thread. Then hop to Main only for the StateFlow emit.
//
// Re-read subs at the terminal step.
// : the snapshot captured at refresh-end may
// include channels the user has since unsubscribed from
// in the ~2s enrich window. Intersect so a freshly-
// unsubscribed channel doesn't briefly re-appear in the
// feed after the enrich emit.:
// hoist the snapshot-URL set once instead of rebuilding
// it per filter iteration.
val snapshotUrls = channelsSnapshot.mapTo(HashSet()) { it.url }
val mergeChannels = Subscriptions.get().subs.value
.filter { it.url in snapshotUrls }
val merged = withContext(Dispatchers.Default) {
mergeFromCache(mergeChannels)
}
// Honor cancellation post-merge
coroutineContext.ensureActive()
_ui.update { it.copy(items = merged) }
}
}
private val ENRICH_HEAD_COUNT = 30
private val ENRICH_PARALLELISM = 8
/**
* Apply an enrichment overlay to a StreamItem. Only fills fields
* that RSS left empty if the source already had non-zero values
* (e.g. a channelInfo path populated them) we don't clobber.
*/
private fun StreamItem.withEnrichment(
enrichments: Map<String, Enrichment>,
): StreamItem {
if (viewCount > 0L && durationSeconds > 0L) return this
val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(url) ?: return this
val e = enrichments[videoId] ?: return this
return copy(
viewCount = if (viewCount > 0L) viewCount else e.viewCount,
durationSeconds = if (durationSeconds > 0L) durationSeconds else e.durationSeconds,
)
}
/**
* Clear in-memory cache. Called from Settings when the user flips
* off the local-cache toggle disk wipe via FeedCacheStore.clear()
* was already there, but the VM kept its in-memory mirror so items
* stayed visible until process death. audit MED-C13.
*/
fun clearInMemoryCache() {
// Cancel any in-flight refresh — without this, fetchChannelInto
// coroutines mid-execution would re-populate the cache after
// the clear. Also cancel any
// enrichment fan-out (lives on globalScope, NOT viewModelScope)
// — otherwise a still-running enrichment would write to
// FeedEnrichment + then push a merged emit reading the empty
// channelCache.
inFlight?.cancel()
enrichJob?.cancel()
channelCache.clear()
// Use _ui.update for atomicity vs concurrent refresh writes
_ui.update { it.copy(items = emptyList(), lastFetchedAt = 0L) }
}
}
/**
* Convert "2 days ago" / "3 weeks ago" / "Streamed 5 hours ago" style
* strings into approximate seconds-ago. Higher = more recent (so default
* sort is descending). Returns Long.MIN_VALUE when we can't parse those
* sink to the bottom of the feed.
*
* Strawcore-core (and YT before it) emits these in English-only locale
* for the InnerTube web client; if we ever localize the extractor this
* regex needs to grow.
*/
private val RECENCY_RE = Regex(
"""(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago""",
RegexOption.IGNORE_CASE,
)
private fun StreamItem.recencyScore(): Long {
val s = uploadDateRelative
if (s.isBlank()) return Long.MIN_VALUE
val m = RECENCY_RE.find(s) ?: return Long.MIN_VALUE
val n = m.groupValues[1].toLongOrNull() ?: return Long.MIN_VALUE
val unitSecs: Long = when (m.groupValues[2].lowercase()) {
"second" -> 1
"minute" -> 60
"hour" -> 3600
"day" -> 86_400
"week" -> 604_800
"month" -> 2_592_000 // approx 30 days
"year" -> 31_536_000
else -> return Long.MIN_VALUE
}
// Sign flip: smaller "seconds ago" → larger score (more recent).
// Cap at a sane horizon so a "1 second ago" doesn't overwhelm the
// viewCount tiebreaker on items that are functionally tied.
return -(n * unitSecs)
}

View file

@ -0,0 +1,179 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The minibar: a thin persistent strip pinned to the bottom of every
* non-Player screen whenever a video is loaded into the MediaController.
* Tap to expand back to fullscreen. The × clears playback and dismisses.
*
* The actual player + audio lives in PlaybackService this composable
* is purely UI on top of the MediaController. Pause/play toggles the
* controller, which is the same player feeding the fullscreen surface
* and the inline detail player. There is only ever one player.
*/
package com.sulkta.straw.feature.player
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import coil3.compose.AsyncImage
@OptIn(UnstableApi::class)
@Composable
fun MinibarOverlay(
onExpand: () -> Unit,
modifier: Modifier = Modifier,
) {
val controller = LocalStrawController.current
val item by NowPlaying.current.collectAsStateWithLifecycle()
if (controller == null || item == null) return
val cur = item ?: return
// Reflect the controller's play state in the play/pause icon. Listening
// is the only reliable way; isPlaying snapshots stale between events.
var isPlaying by remember { mutableStateOf(controller.isPlaying) }
val ctx = androidx.compose.ui.platform.LocalContext.current
DisposableEffect(controller) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
// audit MED-Q11: if Background-button took the user
// to Home and the foreground audio fails, the only Player
// surface still listening is this minibar.
// + Q11: also stop the controller so a
// future tap doesn't seek into the dead state, AND clear
// NowPlaying so the minibar hides itself. (PlayerScreen
// and VideoDetailScreen's listeners also clear NowPlaying
// now, so this is the fallback when neither is alive.)
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
android.widget.Toast.makeText(
ctx,
"playback error: ${error.errorCodeName}",
android.widget.Toast.LENGTH_LONG,
).show()
runCatching {
controller.stop()
controller.clearMediaItems()
}
NowPlaying.clear()
}
}
controller.addListener(listener)
isPlaying = controller.isPlaying
onDispose { controller.removeListener(listener) }
}
// navigationBarsPadding shifts the whole minibar up by the system
// nav-bar height so the bar sits ABOVE the gesture pill / 3-button
// nav, not behind them. enableEdgeToEdge in StrawActivity means
// anything aligned BottomCenter lands under those buttons otherwise.
Column(modifier = modifier.fillMaxWidth().navigationBarsPadding()) {
HorizontalDivider()
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.clickable(onClick = onExpand),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 8.dp),
) {
AsyncImage(
model = cur.thumbnail,
contentDescription = null,
modifier = Modifier
.size(width = 80.dp, height = 48.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color.Black),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
cur.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
cur.uploader,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MinibarIconButton(
icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
desc = if (isPlaying) "Pause" else "Play",
) {
if (controller.isPlaying) controller.pause() else controller.play()
}
MinibarIconButton(icon = Icons.Filled.Close, desc = "Stop") {
controller.stop()
controller.clearMediaItems()
NowPlaying.clear()
}
}
}
}
}
}
@Composable
private fun MinibarIconButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
desc: String,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(22.dp))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(imageVector = icon, contentDescription = desc, modifier = Modifier.size(22.dp))
}
}

View file

@ -0,0 +1,81 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Singleton "currently active video" state drives the minibar overlay
* and tells screens whether their video matches what's playing. Updated
* by whichever surface starts playback (VideoDetail tap, Player Play
* button, playlist item tap). Cleared by the minibar's × button.
*
* Why a process-wide singleton instead of a ViewModel: the minibar is
* rendered at the activity layout level and needs to outlive any
* specific Screen.* composable. Same shape as Subscriptions / Playlists
* runtime-only here since there's no persistence (session-scoped).
*/
package com.sulkta.straw.feature.player
import com.sulkta.straw.net.SbSegment
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class NowPlayingItem(
val streamUrl: String,
val title: String,
val uploader: String,
/**
* Uploader's channel URL needed by the autoplay path so the
* end-of-video handler can call channelInfo() to find the next
* same-channel candidate. Optional because some items come from
* paths where we don't have it (deep links, history rows on a
* cold start before strawcore has resolved metadata).
*/
val uploaderUrl: String? = null,
val thumbnail: String?,
val segments: List<SbSegment> = emptyList(),
)
object NowPlaying {
private val _current = MutableStateFlow<NowPlayingItem?>(null)
val current: StateFlow<NowPlayingItem?> = _current.asStateFlow()
/**
* Atomically claim playback for `streamUrl`. Returns true if this
* call WON the claim (caller should now do setMediaItem + prepare +
* play). Returns false if someone else has already set the same
* streamUrl typically because the inline-player effect and the
* fullscreen Player effect both fired in the same window during
* an inlinefullscreen transition. The losing caller does nothing;
* the winning caller's playback is already in flight.
*
* Uses MutableStateFlow.compareAndSet for the race-free transition.
* audit HIGH-C6 the previous "check NowPlaying then
* direct assign" sequence had a window where both checks could
* pass before either write happened. The non-CAS `set()` setter
* that lived alongside this method was dropped in round-5 (no
* external callers; left in code purely as a footgun).
*/
fun claim(item: NowPlayingItem): Boolean {
while (true) {
val cur = _current.value
if (cur?.streamUrl == item.streamUrl) {
// Same URL — caller doesn't need to re-prepare the
// player, but if it brought richer metadata (full
// title vs the search-result truncation, fresh
// thumbnail, updated SponsorBlock segments) refresh
// those fields.
if (cur != item) _current.compareAndSet(cur, item)
return false
}
if (_current.compareAndSet(cur, item)) return true
// Lost the CAS to a concurrent writer — retry against the
// fresh state. Bounded: at most a handful of competing
// callers in practice.
}
}
fun clear() {
_current.value = null
}
}

View file

@ -2,159 +2,376 @@
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase S: foreground-service ExoPlayer for "Background" audio mode.
* Independent of the activity-side player. When the user taps Background
* on the player overlay, the activity stops its own playback and starts
* this service with the audio URL. Audio continues even if the activity
* is killed (swipe out of recents).
* Universal player for Straw. Owns the single ExoPlayer + MediaSession.
* Every UI surface (inline player on VideoDetail, fullscreen PlayerScreen,
* the minibar overlay) is a MediaController client talking to this
* session so playback never restarts on a screen transition and a
* dragged-down player just keeps going at the bottom of the layout.
*
* Audit fixes (2026-05-24 pass #2):
* CRIT-1: call startForeground() immediately on first onStartCommand so
* Android 12+ doesn't kill the process with
* ForegroundServiceDidNotStartInTimeException after the 5s window.
* HIGH-2: return START_NOT_STICKY when there is no playable URL the
* OS will not relaunch us with a null intent and crash-loop.
* HIGH-3: stop the service when playback ends (Player.Listener) so the
* WAKE_LOCK / foreground notification doesn't linger.
* MED-1: null the field before releasing the session to close a tiny
* onGetSession race during teardown.
* The service is brought up automatically the first time the activity
* builds a MediaController against `SessionToken(ctx, ComponentName)`.
* It transitions to foreground when playback starts (Media3 handles the
* required notification); it stops itself when idle (no controllers
* connected AND nothing in the queue).
*
* Limitations:
* - Single URL only. The activity-side merged-DASH path doesn't carry
* over (we just use the best audioStream). Acceptable trade-off for
* background mode.
* - No SponsorBlock skip here. That logic lives in PlayerScreen and is
* foreground-only for now.
* - Service plays one item at a time. Queue/playlist is future work.
* Media source dispatch lives in [StrawMediaSourceFactory] below. It
* routes by MIME type for DASH / HLS / progressive and merges video +
* audio when the audio URL is carried in the MediaItem's
* `requestMetadata.extras[EXTRA_AUDIO_URL]`.
*/
package com.sulkta.straw.feature.player
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.sulkta.straw.StrawActivity
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.StrawApp
import com.sulkta.straw.data.AutoplayMode
import com.sulkta.straw.data.History
import com.sulkta.straw.data.Resume
import com.sulkta.straw.data.Settings
import com.sulkta.straw.feature.detail.resolveStreamPlayback
import com.sulkta.straw.net.IosSafeHttpDataSource
import com.sulkta.straw.net.STRAW_USER_AGENT
import com.sulkta.straw.net.SponsorBlockClient
import com.sulkta.straw.util.isAllowedYtUrl
import com.sulkta.straw.util.runCatchingCancellable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@UnstableApi
class PlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null
private var foregroundStarted = false
private var settingsWatcherJob: Job? = null
private var resumePollJob: Job? = null
override fun onCreate() {
super.onCreate()
ensureChannel()
val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent(NewPipeDownloader.USER_AGENT)
.setAllowCrossProtocolRedirects(true)
val mediaSourceFactory = DefaultMediaSourceFactory(this)
.setDataSourceFactory(httpFactory)
// Path C-7: wrap in IosSafeHttpDataSource so ExoPlayer's open-ended
// Range requests get chunked into bounded reads. iOS-bound
// googlevideo URLs 403 on `Range: bytes=N-` but accept `Range:
// bytes=N-M`.
val httpFactory = IosSafeHttpDataSource.Factory(
DefaultHttpDataSource.Factory()
.setUserAgent(STRAW_USER_AGENT)
.setAllowCrossProtocolRedirects(true)
)
val mediaSourceFactory = StrawMediaSourceFactory(httpFactory)
val player = ExoPlayer.Builder(this)
.setMediaSourceFactory(mediaSourceFactory)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build(),
/* handleAudioFocus = */ true,
)
// Honor the user's pause-on-headphone-disconnect preference
// at construction time. The Settings flow is also watched
// below so flipping it mid-session takes effect immediately.
.setHandleAudioBecomingNoisy(
Settings.get().pauseOnHeadphoneDisconnect.value,
)
.build()
// HIGH-3: end-of-playback should release the foreground slot.
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED || state == Player.STATE_IDLE) {
stopSelf()
}
}
})
// Service shutdown is driven by onTaskRemoved (user swiped app away)
// + the user pressing × on the minibar (which clears the queue).
// Don't auto-stop on STATE_ENDED — a future autoplay/queue feature
// expects the service to stay alive between items in the queue.
// Foreground notification fades on its own when nothing is playing.
val sessionActivityIntent = PendingIntent.getActivity(
this,
0,
Intent(this, StrawActivity::class.java),
Intent(this, StrawActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
},
PendingIntent.FLAG_IMMUTABLE,
)
mediaSession = MediaSession.Builder(this, player)
.setId(MEDIA_SESSION_ID)
.setSessionActivity(sessionActivityIntent)
.build()
// Watch the pause-on-headphone-disconnect setting so flipping
// it in Settings takes effect on this already-built ExoPlayer
// without requiring a service restart. The initial value was
// baked in via the builder above — this picks up subsequent
// flips.
settingsWatcherJob = StrawApp.globalScope.launch {
Settings.get().pauseOnHeadphoneDisconnect.collect { handle ->
player.setHandleAudioBecomingNoisy(handle)
}
}
// Queue auto-advance bridge: when Media3 transitions to the
// next item in the queue, look up the matching NowPlayingItem
// (with original streamUrl, uploader, thumbnail, SB segments)
// and push it into NowPlaying so the minibar + SponsorBlock
// skip-loop reflect the new track. claim() handles concurrent
// setPlayingFrom races — see audit HIGH-C6.
//
// SponsorBlock for queued items: when a queued item's segments
// are empty (which they always are — enqueueNext/Last doesn't
// pre-fetch SB to avoid the network round-trip on every long-
// press), kick off a background fetch and re-claim with the
// freshened segments. NowPlaying.claim handles the
// "same-streamUrl with fresher metadata" case via its CAS.
//
// Autoplay at end-of-queue: when STATE_ENDED fires and there's
// no next item in the queue, consult Settings.autoplayMode and
// pick a candidate. SameChannel → call channelInfo on the
// current uploader, take the first un-watched (gated on
// autoplaySkipWatched). YtRelated → would re-call streamInfo
// and pick info.related[0] but strawcore returns empty for
// related today, so it's a no-op until that lands.
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
if (item == null) return
val idx = player.currentMediaItemIndex
val queued = Queue.at(idx)
if (queued != null) {
NowPlaying.claim(queued)
if (queued.segments.isEmpty()) {
val videoId =
com.sulkta.straw.feature.detail.extractYtVideoId(queued.streamUrl)
if (!videoId.isNullOrBlank()) fetchSbForQueued(queued, videoId)
}
return
}
// Queue desync — MediaItem was added by a path that
// bypassed enqueueInternal, OR the queue was cleared
// while a transition was pending. Fall back to the
// MediaItem's own metadata so NowPlaying doesn't stay
// stuck on the previous video forever (would freeze
// VideoDetail's controllerOnThisVideo guard at false
// and lock the inline player into thumbnail+spinner).
val uri = item.localConfiguration?.uri?.toString() ?: return
val fallback = NowPlayingItem(
streamUrl = uri,
title = item.mediaMetadata.title?.toString().orEmpty(),
uploader = item.mediaMetadata.artist?.toString().orEmpty(),
thumbnail = item.mediaMetadata.artworkUri?.toString(),
)
NowPlaying.claim(fallback)
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
// Capture on every play→pause edge. Covers user taps,
// audio focus loss, headphone-noisy pause. The 5s poll
// covers the play-through case.
if (!isPlaying) captureResumePosition(player)
}
override fun onPlaybackStateChanged(state: Int) {
if (state != Player.STATE_ENDED) return
val mode = Settings.get().autoplayMode.value
if (mode == AutoplayMode.Off) return
// Media3 auto-advances inside the queue; we only kick
// in when the queue has truly run out. mediaItemCount
// hits 0 after the engine reports STATE_ENDED in some
// edge cases — handle both.
val atEnd = player.mediaItemCount <= 1 ||
player.currentMediaItemIndex >= player.mediaItemCount - 1
if (!atEnd) return
tryAutoplay(mode)
}
})
// Periodic scrub-point write. Stays on Main so player reads are
// thread-safe; the SP write inside record() is async (apply()).
// 5s cadence is the sweet spot — finer is wasted disk churn,
// coarser loses too much on a sudden process death.
resumePollJob = StrawApp.globalScope.launch(Dispatchers.Main) {
while (isActive) {
delay(RESUME_POLL_INTERVAL_MS)
captureResumePosition(player)
}
}
}
/**
* Read the current player position and persist it to the
* ResumePositionsStore. Bails on idle/ended states and unknown
* durations (live streams). The store itself enforces minimum-
* position + near-end-clear thresholds.
*
* Gates STRICTLY on STATE_READY. STATE_BUFFERING during a fresh
* setMediaItem still reports the PREVIOUS item's position via
* currentPosition until prepare finishes and the new timeline
* lands without the gate we'd record A's tail position under
* B's videoId and auto-resume the user mid-A on next open.
*/
private fun captureResumePosition(player: Player) {
val state = player.playbackState
if (state != Player.STATE_READY) return
val item = NowPlaying.current.value ?: return
val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(item.streamUrl) ?: return
val pos = player.currentPosition
val dur = player.duration
if (dur <= 0L) return
Resume.get().record(videoId, pos, dur)
}
private fun fetchSbForQueued(item: NowPlayingItem, videoId: String) {
StrawApp.globalScope.launch {
runCatchingCancellable {
val cats = Settings.get().sbCategories.value.map { it.key }
if (cats.isEmpty()) return@runCatchingCancellable
val segments = withContext(Dispatchers.IO) {
SponsorBlockClient.fetch(videoId, cats)
}
if (segments.isNotEmpty()) {
NowPlaying.claim(item.copy(segments = segments))
}
}
}
}
private fun tryAutoplay(mode: AutoplayMode) {
val current = NowPlaying.current.value ?: return
val uploaderUrl = current.uploaderUrl
// We need the channel URL for the SameChannel path; YtRelated
// re-resolves the current video's info. If we don't have what
// we need, silently bail — better than a half-baked surprise.
val controller = (mediaSession?.player as? Player) ?: return
StrawApp.globalScope.launch {
runCatchingCancellable {
val candidateUrl = withContext(Dispatchers.IO) {
pickAutoplayCandidate(mode, current.streamUrl, uploaderUrl)
} ?: return@runCatchingCancellable
// Final allowlist gate before we hit strawcore with a
// URL whose origin was the extractor. Same defense as
// VideoDetailViewModel.load. /
// HIGH-3 family — every uniffi.strawcore.* site that
// takes a user-influenced URL needs this gate.
if (!isAllowedYtUrl(candidateUrl)) return@runCatchingCancellable
// Resolve + enqueue + auto-play. Because the queue is
// currently empty (we just ended), enqueueLast routes
// through setPlayingFrom (auto-starts).
val info = withContext(Dispatchers.IO) {
uniffi.strawcore.streamInfo(candidateUrl)
}
val resolved = resolveStreamPlayback(info)
withContext(Dispatchers.Main) {
controller.enqueueLast(
streamUrl = candidateUrl,
title = info.title,
uploader = info.uploader,
thumbnail = info.thumbnail,
resolved = resolved,
uploaderUrl = info.uploaderUrl,
)
}
}
}
}
private suspend fun pickAutoplayCandidate(
mode: AutoplayMode,
currentStreamUrl: String,
uploaderUrl: String?,
): String? {
val watched = if (Settings.get().autoplaySkipWatched.value) {
History.get().watches.value.map { it.videoId }.toSet()
} else emptySet()
fun unwatched(url: String): Boolean {
if (watched.isEmpty()) return true
val id = com.sulkta.straw.feature.detail.extractYtVideoId(url)
return id == null || id !in watched
}
return try {
when (mode) {
AutoplayMode.Off -> null
AutoplayMode.SameChannel -> {
if (uploaderUrl.isNullOrBlank()) return null
// uploaderUrl came from the extractor and flows
// through NowPlaying without revalidation. Same
// gate as the inline channelInfo path.
if (!isAllowedYtUrl(uploaderUrl)) return null
val ch = uniffi.strawcore.channelInfo(uploaderUrl)
ch.videos
.asSequence()
.filter { it.url != currentStreamUrl }
.filter { unwatched(it.url) }
.firstOrNull()?.url
}
AutoplayMode.YtRelated -> {
val info = uniffi.strawcore.streamInfo(currentStreamUrl)
info.related
.asSequence()
.filter { it.url != currentStreamUrl }
.filter { unwatched(it.url) }
.firstOrNull()?.url
}
}
} catch (c: kotlinx.coroutines.CancellationException) {
throw c
} catch (_: Throwable) {
null
}
}
override fun onGetSession(
controllerInfo: MediaSession.ControllerInfo,
): MediaSession? = mediaSession
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
// CRIT-1: must startForeground within ~5s of startForegroundService,
// before anything that can throw or block.
startForegroundCompat()
val url = intent?.getStringExtra(EXTRA_URL)?.takeIf { isAllowedAudioUrl(it) }
val title = intent?.getStringExtra(EXTRA_TITLE)
val uploader = intent?.getStringExtra(EXTRA_UPLOADER)
val player = mediaSession?.player
if (url == null || player == null) {
// HIGH-2: nothing to play (likely a re-launch with null intent
// after a kill). Tear down so we don't sit holding the FG slot.
stopSelf()
return START_NOT_STICKY
}
val item = MediaItem.Builder()
.setUri(url)
.setMediaMetadata(
androidx.media3.common.MediaMetadata.Builder()
.setTitle(title ?: "")
.setArtist(uploader ?: "")
.build(),
)
.build()
player.setMediaItem(item)
player.prepare()
player.playWhenReady = true
return START_NOT_STICKY
}
/**
* When the user swipes the app out of Recents, only kill the service
* if playback isn't running. If the user is intentionally backgrounding
* to keep music going, we stay alive.
*/
override fun onTaskRemoved(rootIntent: Intent?) {
// HIGH-3: keep service alive ONLY while playback is genuinely in
// progress. After STATE_ENDED, playWhenReady stays true but state
// is ENDED — old check missed that and held WAKE_LOCK forever.
val p = mediaSession?.player
val keep = p != null &&
val keepAlive = p != null &&
p.playWhenReady &&
p.mediaItemCount > 0 &&
p.playbackState != Player.STATE_IDLE &&
p.playbackState != Player.STATE_ENDED
if (!keep) stopSelf()
if (!keepAlive) stopSelf()
}
override fun onDestroy() {
// MED-1: null the field first so a late onGetSession from the
// controller-binding teardown gets null instead of a released session.
// Final scrub-point snapshot before teardown — covers swipe-
// away-without-pause case. Read before cancelling the poll
// job (the job's last tick may not have landed yet).
mediaSession?.player?.let { captureResumePosition(it) }
resumePollJob?.cancel()
resumePollJob = null
settingsWatcherJob?.cancel()
settingsWatcherJob = null
// Null the field first so a late onGetSession during teardown gets
// null rather than a released session.
val s = mediaSession
mediaSession = null
s?.player?.release()
@ -162,71 +379,82 @@ class PlaybackService : MediaSessionService() {
super.onDestroy()
}
private fun startForegroundCompat() {
if (foregroundStarted) return
val tap = PendingIntent.getActivity(
this,
0,
Intent(this, StrawActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
val notification: Notification = NotificationCompat.Builder(this, NOTIF_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle("Straw")
.setContentText("Background audio")
.setContentIntent(tap)
.setOngoing(true)
.setCategory(Notification.CATEGORY_TRANSPORT)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIF_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
)
} else {
startForeground(NOTIF_ID, notification)
}
foregroundStarted = true
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(NOTIF_CHANNEL_ID) != null) return
val ch = NotificationChannel(
NOTIF_CHANNEL_ID,
"Background audio",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Straw audio playback while the app is in background"
setShowBadge(false)
}
nm.createNotificationChannel(ch)
}
/**
* HIGH-4 mirror on the service side: the URL in EXTRA_URL came from
* NewPipeExtractor's audioStream.content. Re-validate host + scheme
* before handing it to ExoPlayer's HTTP source. Only YT googlevideo
* hosts allowed; HTTPS only.
*/
private fun isAllowedAudioUrl(url: String): Boolean {
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
if (!uri.scheme.equals("https", ignoreCase = true)) return false
val host = uri.host?.lowercase() ?: return false
return host.endsWith(".googlevideo.com") ||
host.endsWith(".youtube.com") ||
host == "youtube.com"
}
companion object {
const val EXTRA_URL = "com.sulkta.straw.extra.URL"
const val EXTRA_TITLE = "com.sulkta.straw.extra.TITLE"
const val EXTRA_UPLOADER = "com.sulkta.straw.extra.UPLOADER"
const val MEDIA_SESSION_ID = "straw"
private const val NOTIF_CHANNEL_ID = "straw.playback"
private const val NOTIF_ID = 4242
/**
* Bundle key when set on a MediaItem's `requestMetadata.extras`,
* the source factory will merge that audio URL with the
* MediaItem's video URI to produce a combined video+audio source.
*/
const val EXTRA_AUDIO_URL = "straw.audio_url"
/** Scrub-point write cadence while the player is alive. */
private const val RESUME_POLL_INTERVAL_MS = 5_000L
}
}
/**
* MediaSource.Factory that picks the right inner source per MediaItem:
*
* - If `requestMetadata.extras[EXTRA_AUDIO_URL]` is set MergingMediaSource
* (progressive video + progressive audio).
* - Else by MIME: application/dash+xml DASH, application/x-mpegURL HLS,
* everything else progressive.
*
* Lets us drive all stream shapes (DASH MPD, HLS, combined progressive,
* separate video+audio progressive) through the single MediaController API
* without exposing MediaSource directly to the UI layer.
*/
@UnstableApi
class StrawMediaSourceFactory(
private val dataSourceFactory: DataSource.Factory,
) : MediaSource.Factory {
private val dashFactory = DashMediaSource.Factory(dataSourceFactory)
private val hlsFactory = HlsMediaSource.Factory(dataSourceFactory)
private val progFactory = ProgressiveMediaSource.Factory(dataSourceFactory)
// For mime-sniffing fallthroughs we also fall back to DefaultMediaSourceFactory
// so things like extractors-only progressive items keep working.
private val defaultFactory = DefaultMediaSourceFactory(dataSourceFactory)
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
val audioUrl = mediaItem.requestMetadata.extras
?.getString(PlaybackService.EXTRA_AUDIO_URL)
if (audioUrl != null) {
val videoSource = progFactory.createMediaSource(mediaItem)
val audioSource = progFactory.createMediaSource(MediaItem.fromUri(Uri.parse(audioUrl)))
return MergingMediaSource(videoSource, audioSource)
}
val mime = mediaItem.localConfiguration?.mimeType
return when (mime) {
MimeTypes.APPLICATION_MPD -> dashFactory.createMediaSource(mediaItem)
MimeTypes.APPLICATION_M3U8 -> hlsFactory.createMediaSource(mediaItem)
else -> {
// Try progressive first; fall back to the default factory's
// extractor-based selection so generic URIs (e.g. local
// file:// from the downloads dir) still work.
runCatching { progFactory.createMediaSource(mediaItem) }
.getOrElse { defaultFactory.createMediaSource(mediaItem) }
}
}
}
override fun setDrmSessionManagerProvider(p: DrmSessionManagerProvider): MediaSource.Factory {
dashFactory.setDrmSessionManagerProvider(p)
hlsFactory.setDrmSessionManagerProvider(p)
progFactory.setDrmSessionManagerProvider(p)
defaultFactory.setDrmSessionManagerProvider(p)
return this
}
override fun setLoadErrorHandlingPolicy(p: LoadErrorHandlingPolicy): MediaSource.Factory {
dashFactory.setLoadErrorHandlingPolicy(p)
hlsFactory.setLoadErrorHandlingPolicy(p)
progFactory.setLoadErrorHandlingPolicy(p)
defaultFactory.setLoadErrorHandlingPolicy(p)
return this
}
override fun getSupportedTypes(): IntArray =
intArrayOf(C.CONTENT_TYPE_DASH, C.CONTENT_TYPE_HLS, C.CONTENT_TYPE_OTHER)
}

View file

@ -2,43 +2,58 @@
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase C: Media3 PlayerView embedded in Compose.
* Phase D: SponsorBlock auto-skip wired in via position-poll loop.
* Fullscreen player surface. The player itself lives in PlaybackService
* (one ExoPlayer for the whole app); this composable is a thin shell that
* renders a PlayerView bound to the shared MediaController and overlays
* speed / audio-only / share / PiP / minimize controls. To minimize, tap
* the down-arrow button (top right) the swipe-down gesture lives on
* the VideoDetail page instead, where it doesn't fight PlayerView's own
* touch handling. SponsorBlock auto-skip lives at the activity root in
* [SponsorBlockSkipLoop].
*/
package com.sulkta.straw.feature.player
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.ComponentName
import android.content.Intent
import android.os.Build
import android.util.Rational
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Headphones
import androidx.compose.material.icons.filled.PictureInPictureAlt
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -49,29 +64,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.TrackGroup as Media3TrackGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.OverlayChromeColor
import com.sulkta.straw.feature.detail.VideoDetailViewModel
import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.util.LogDump
import com.sulkta.straw.util.strawLogI
import kotlinx.coroutines.delay
@ -80,170 +84,73 @@ import kotlinx.coroutines.delay
fun PlayerScreen(
streamUrl: String,
title: String,
vm: PlayerViewModel = viewModel(),
onMinimize: () -> Unit = {},
vm: VideoDetailViewModel = viewModel(),
) {
val context = LocalContext.current
val controller = LocalStrawController.current
val state by vm.ui.collectAsStateWithLifecycle()
LaunchedEffect(streamUrl) { vm.resolve(streamUrl) }
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
// Local UI state for speed / audio-only / dialog open.
var playbackSpeed by remember { mutableStateOf(1.0f) }
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
var audioOnly by remember { mutableStateOf(false) }
var showSpeedDialog by remember { mutableStateOf(false) }
val exoPlayer = remember {
ExoPlayer.Builder(context)
.setAudioAttributes(
// Tell the system we're playing media so audio focus +
// ducking + Bluetooth routing work, and notifications can
// sit alongside other media apps.
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build(),
/* handleAudioFocus = */ true,
)
.build()
}
// Wrap the player in a MediaSession so the OS gets lock-screen +
// notification media controls while this Activity is alive. Full
// background-audio-after-Activity-kill is M-3 (MediaSessionService +
// MediaController refactor).
val mediaSession = remember {
MediaSession.Builder(context, exoPlayer).build()
}
DisposableEffect(Unit) {
onDispose {
mediaSession.release()
exoPlayer.release()
}
}
// PiP setup: on Android 12+ tell the OS this activity can auto-enter
// PiP, so when the user presses Home or swipes away the video shrinks
// into a floating window instead of pausing/exiting. Aspect ratio is
// set eagerly so the system can sample it before the first transition.
val activity = context as? Activity
DisposableEffect(activity) {
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
.setAutoEnterEnabled(true)
.build()
runCatching { activity.setPictureInPictureParams(params) }
}
onDispose {
// Disable auto-enter when leaving the player so the rest of the
// app doesn't accidentally PiP on background.
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val off = PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
runCatching { activity.setPictureInPictureParams(off) }
}
}
}
// AUD-MED: pause playback when app goes to background. Without this,
// ExoPlayer keeps playing audio with no MediaSession — user can't pause
// from the notification shade. EXCEPTION: don't pause when entering
// Picture-in-Picture mode (that's the whole point of PiP).
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
val activity = context as? Activity
if (activity?.isInPictureInPictureMode != true) {
exoPlayer.pause()
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// When the resolved playback for this URL is ready, push it into the
// shared controller — unless it's already playing this exact URL, in
// which case do nothing: the player is already where we want it. The
// previous "seek-to-self" path here was always a few ms backwards and
// produced a jerk on every entry; the controller's currentPosition is
// its own source of truth.
val resolved = state.resolved
LaunchedEffect(resolved) {
val detail = state.detail
LaunchedEffect(controller, resolved, detail) {
val c = controller ?: return@LaunchedEffect
val r = resolved ?: return@LaunchedEffect
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(NewPipeDownloader.USER_AGENT)
.setAllowCrossProtocolRedirects(true)
val source = when {
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.dashMpdUrl))
r.hlsUrl != null -> HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.hlsUrl))
r.combinedUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.combinedUrl))
r.videoUrl != null && r.audioUrl != null -> {
val v = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.videoUrl))
val a = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.audioUrl))
MergingMediaSource(v, a)
}
r.videoUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.videoUrl))
else -> null
}
if (source != null) {
exoPlayer.setMediaSource(source)
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
val uploader = detail?.uploader.orEmpty()
val thumbnail = detail?.thumbnail
// Optimization, not safety. claim() guards the race.
if (NowPlaying.current.value?.streamUrl == streamUrl) return@LaunchedEffect
c.setPlayingFrom(
streamUrl = streamUrl,
title = title,
uploader = uploader,
thumbnail = thumbnail,
resolved = r,
uploaderUrl = detail?.uploaderUrl,
)
}
// SponsorBlock auto-skip — poll position every 150ms, seek past any segment.
// AUD-HIGH fixes vs initial impl:
// - dedup skipped segments via UUID so re-listen doesn't fight the user
// - tighter poll (150ms) reduces sponsor leak through buffering window
// - check playbackState != IDLE/ENDED (was isPlaying, which is false
// during buffering and missed the skip window)
// - clamp seek target away from duration boundary to avoid jank
val skippedUuids = remember { mutableSetOf<String>() }
LaunchedEffect(resolved?.segments) {
val segments = resolved?.segments ?: return@LaunchedEffect
if (segments.isEmpty()) return@LaunchedEffect
skippedUuids.clear()
while (true) {
delay(150)
val state = exoPlayer.playbackState
if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) continue
val posSec = exoPlayer.currentPosition / 1000.0
val segment = pickActiveSegment(segments, posSec, skippedUuids) ?: continue
strawLogI(
"StrawSb",
"skip: ${segment.category} ${segment.startSec}s..${segment.endSec}s (pos=$posSec)",
)
val targetMs = (segment.endSec * 1000).toLong()
val durationMs = exoPlayer.duration
if (durationMs > 0 && targetMs >= durationMs - 500) {
// Past end — let it end naturally rather than seeking past content.
exoPlayer.seekTo(durationMs - 1)
} else {
exoPlayer.seekTo(targetMs)
// Surface ExoPlayer failures from the service into the UI.
var playbackError by remember { mutableStateOf<String?>(null) }
DisposableEffect(controller) {
val c = controller
val listener = object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
// Scrub the message before rendering. Media3's
// HttpDataSource exceptions embed the full request URI
// (with signature= / pot= / cpn=) in the .message
// string — visible in the on-screen error banner and
// a screenshot away from being shared.
val raw = error.message ?: "(no message)"
playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
// Also clear NowPlaying so the minibar doesn't keep
// claiming a dead session is loaded.
NowPlaying.clear()
}
segment.UUID?.let { skippedUuids.add(it) }
Toast.makeText(context, "skipped ${segment.category}", Toast.LENGTH_SHORT).show()
}
c?.addListener(listener)
onDispose { c?.removeListener(listener) }
}
val activity = context as? Activity
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
when {
state.loading -> CircularProgressIndicator()
state.loading || controller == null -> CircularProgressIndicator()
state.error != null -> Text(
"playback error: ${state.error}",
@ -251,52 +158,83 @@ fun PlayerScreen(
modifier = Modifier.padding(16.dp),
)
playbackError != null -> Text(
"playback error: $playbackError",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
resolved?.isPlayable != true -> Text(
"no playable stream found",
modifier = Modifier.padding(16.dp),
)
else -> {
// Video surface — bleeds full-screen including under any
// display cutout / camera notch. Looks more immersive.
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
player = controller
useController = true
// Keep the last frame on screen when this
// view's player is reset (fullscreen →
// inline transition). Without this, the
// detaching PlayerView flashes black for
// ~1 frame before the receiving view takes
// over the surface.
controllerHideOnTouch = true
setKeepContentOnPlayerReset(true)
// Don't let the device timeout/lock while
// a fullscreen video is on-screen. View-
// level flag — propagates to the window
// while attached, clears on detach so
// backing out of fullscreen releases the
// wake-lock automatically. Mirror on the
// inline PlayerView for consistency.
keepScreenOn = true
}
},
update = { it.player = controller },
onRelease = { it.player = null },
modifier = Modifier.fillMaxSize(),
)
// SponsorBlock segment count badge — small overlay top-left.
resolved?.let { r ->
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(12.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0xCC222222))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
text = "SB: ${r.segments.size} segment${if (r.segments.size == 1) "" else "s"}",
color = Color.White,
style = MaterialTheme.typography.labelSmall,
)
}
// Overlay controls layer — sits inside the safe area so
// buttons don't get eaten by the notch in portrait or by
// a side cutout in landscape. SafeDrawing covers system
// bars + display cutouts in one go.
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(12.dp)
.clip(RoundedCornerShape(6.dp))
.background(OverlayChromeColor)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
text = "SB: ${resolved.segments.size} segment${if (resolved.segments.size == 1) "" else "s"}",
color = Color.White,
style = MaterialTheme.typography.labelSmall,
)
}
// Top-right overlay — speed / audio-only / share / PiP.
Row(
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Playback speed
OverlayButton(label = if (playbackSpeed == 1f) "1×" else "${playbackSpeed}×") {
OverlayIconButton(icon = Icons.Filled.Speed, desc = "Playback speed") {
showSpeedDialog = true
}
// Audio-only toggle
OverlayButton(label = if (audioOnly) "📻" else "📺") {
OverlayIconButton(
icon = if (audioOnly) Icons.Filled.Headphones else Icons.Filled.Videocam,
desc = if (audioOnly) "Audio-only on" else "Video on",
) {
audioOnly = !audioOnly
// Disable / enable video renderer via track-selection params.
exoPlayer.trackSelectionParameters = TrackSelectionParameters.Builder(context)
controller.trackSelectionParameters = TrackSelectionParameters.Builder(context)
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, audioOnly)
.build()
Toast.makeText(
@ -305,8 +243,7 @@ fun PlayerScreen(
Toast.LENGTH_SHORT,
).show()
}
// Share
OverlayButton(label = "") {
OverlayIconButton(icon = Icons.Filled.Share, desc = "Share") {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, streamUrl)
@ -314,72 +251,38 @@ fun PlayerScreen(
}
context.startActivity(Intent.createChooser(send, "Share video"))
}
// PiP — manual entry (auto-enter on home gesture is wired
// up via the DisposableEffect above on Android 12+).
OverlayButton(label = "") {
val act = (context as? Activity)
if (act == null) {
OverlayIconButton(icon = Icons.Filled.PictureInPictureAlt, desc = "Picture in picture") {
if (activity == null) {
Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show()
return@OverlayButton
return@OverlayIconButton
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show()
return@OverlayButton
return@OverlayIconButton
}
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
.build()
val result = runCatching { act.enterPictureInPictureMode(params) }
result.onSuccess { ok ->
if (!ok) {
Toast.makeText(
context,
"PiP refused — check Settings > Apps > Straw > PiP",
Toast.LENGTH_LONG,
).show()
runCatching { activity.enterPictureInPictureMode(params) }
.onSuccess { ok ->
if (!ok) Toast.makeText(context, "PiP refused", Toast.LENGTH_LONG).show()
}
.onFailure { t ->
Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show()
}
}
result.onFailure { t ->
Toast.makeText(
context,
"PiP failed: ${t.message ?: t.javaClass.simpleName}",
Toast.LENGTH_LONG,
).show()
}
}
// Background audio (phase S) — independent foreground-service playback.
// Audit HIGH-1: handing off, not dual-hosting. Stop activity's player
// first so the OS sees a single MediaSession (cleaner lockscreen +
// audio focus) and we don't leak two active ExoPlayers.
OverlayButton(label = "🎧") {
val r = resolved ?: return@OverlayButton
val audio = r.audioUrl ?: r.combinedUrl
if (audio == null) {
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
return@OverlayButton
}
runCatching { exoPlayer.stop() }
runCatching { exoPlayer.clearMediaItems() }
val intent = Intent(context, PlaybackService::class.java).apply {
component = ComponentName(context, PlaybackService::class.java)
putExtra(PlaybackService.EXTRA_URL, audio)
putExtra(PlaybackService.EXTRA_TITLE, title)
}
ContextCompat.startForegroundService(context, intent)
Toast.makeText(
context,
"background audio started — close the app whenever",
Toast.LENGTH_SHORT,
).show()
OverlayIconButton(icon = Icons.Filled.KeyboardArrowDown, desc = "Minimize") {
onMinimize()
}
}
} // close safe-area overlay Box
if (showSpeedDialog) {
SpeedPickerDialog(
current = playbackSpeed,
onPick = { s ->
playbackSpeed = s
exoPlayer.playbackParameters = PlaybackParameters(s)
controller.playbackParameters = PlaybackParameters(s)
showSpeedDialog = false
},
onDismiss = { showSpeedDialog = false },
@ -391,16 +294,25 @@ fun PlayerScreen(
}
@Composable
private fun OverlayButton(label: String, onClick: () -> Unit) {
private fun OverlayIconButton(
icon: ImageVector,
desc: String,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0xCC222222))
.background(OverlayChromeColor)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(label, color = Color.White, style = MaterialTheme.typography.titleSmall)
Icon(
imageVector = icon,
contentDescription = desc,
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
@ -419,7 +331,7 @@ private fun SpeedPickerDialog(
options.forEach { s ->
Row(
modifier = Modifier
.fillMaxSize()
.fillMaxWidth()
.clickable { onPick(s) }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
@ -441,9 +353,63 @@ private fun SpeedPickerDialog(
}
/**
* Returns the segment whose interval contains [posSec], if any, skipping
* UUIDs in [skipped]. Filters out POI-style point segments (start == end).
* SponsorBlock skip loop driven by the controller's currentPosition.
* Lives at the activity composition root so it skips segments whether
* the user is fullscreen, in the minibar, or away from the player
* surface.
*
* The `skipped` set is only mutated from this single coroutine safe
* without synchronization while that invariant holds.
*/
@Composable
@OptIn(UnstableApi::class)
fun SponsorBlockSkipLoop() {
val controller = LocalStrawController.current
val context = LocalContext.current
val item by NowPlaying.current.collectAsStateWithLifecycle()
val cur = item ?: return
val segments = cur.segments
if (segments.isEmpty() || controller == null) return
val skipped = remember(cur.streamUrl) { mutableSetOf<String>() }
// Rate-limit the skip Toast — back-to-back segments in
// sponsor-dense videos used to queue 20+ Toasts that paint over
// the screen for 40s after the actual seek ( audit HIGH-B7).
var lastToastAt by remember(cur.streamUrl) { mutableStateOf(0L) }
LaunchedEffect(cur.streamUrl, controller) {
while (true) {
delay(150)
val state = controller.playbackState
if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) continue
// Skip the position read + segment scan when not actively
// playing — on a paused-overnight session the prior shape
// hit the binder every 150ms for hours.
if (!controller.isPlaying) {
delay(1000)
continue
}
val posSec = controller.currentPosition / 1000.0
val s = pickActiveSegment(segments, posSec, skipped) ?: continue
strawLogI(
"StrawSb",
"skip: ${s.category} ${s.startSec}s..${s.endSec}s (pos=$posSec)",
)
val targetMs = (s.endSec * 1000).toLong()
val durationMs = controller.duration
if (durationMs > 0 && targetMs >= durationMs - 500) {
controller.seekTo(durationMs - 1)
} else {
controller.seekTo(targetMs)
}
s.UUID?.let { skipped.add(it) }
val now = System.currentTimeMillis()
if (now - lastToastAt > 3000) {
Toast.makeText(context, "skipped ${s.category}", Toast.LENGTH_SHORT).show()
lastToastAt = now
}
}
}
}
private fun pickActiveSegment(
segments: List<SbSegment>,
posSec: Double,
@ -451,5 +417,8 @@ private fun pickActiveSegment(
): SbSegment? = segments.firstOrNull { s ->
val uuidNotSkipped = s.UUID == null || s.UUID !in skipped
val interval = s.endSec - s.startSec > 0.1
uuidNotSkipped && interval && posSec >= s.startSec && posSec < s.endSec - 0.05
// Drop the prior -0.05 exclusion — combined with the loop's
// 150ms polling cadence, short SB segments could fall in the
// gap and silently skip the skip.
uuidNotSkipped && interval && posSec >= s.startSec && posSec < s.endSec
}

View file

@ -1,106 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.sulkta.straw.feature.player
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.MaxResolution
import com.sulkta.straw.data.Settings
import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.net.SponsorBlockClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.stream.StreamInfo
data class ResolvedPlayback(
val title: String,
val videoUrl: String?,
val audioUrl: String?,
val combinedUrl: String?,
val dashMpdUrl: String?,
val hlsUrl: String?,
val segments: List<SbSegment> = emptyList(),
) {
/** Have anything playable? */
val isPlayable: Boolean
get() = !combinedUrl.isNullOrBlank() || !videoUrl.isNullOrBlank() ||
!dashMpdUrl.isNullOrBlank() || !hlsUrl.isNullOrBlank()
}
data class PlayerUiState(
val loading: Boolean = true,
val resolved: ResolvedPlayback? = null,
val error: String? = null,
)
class PlayerViewModel : ViewModel() {
private val _ui = MutableStateFlow(PlayerUiState())
val ui: StateFlow<PlayerUiState> = _ui.asStateFlow()
fun resolve(streamUrl: String) {
_ui.value = PlayerUiState(loading = true)
viewModelScope.launch {
try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
val videoId = info.id
val sbCategories = Settings.get().sbCategories.value.map { it.key }
val segments = if (sbCategories.isEmpty()) {
emptyList()
} else {
withContext(Dispatchers.IO) {
runCatching { SponsorBlockClient.fetch(videoId, sbCategories) }
.getOrDefault(emptyList())
}
}
val maxRes = Settings.get().maxResolution.value.ceiling
fun heightOf(q: String?): Int =
q?.removeSuffix("p")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0
// Audit HIGH-8: when no stream is under the resolution ceiling
// (e.g. user picked 144p but the video only has 360p+), fall
// back to the lowest-resolution available instead of returning
// null and showing a black-screen player.
fun pickVideo(streams: List<org.schabi.newpipe.extractor.stream.VideoStream>?): String? {
if (streams.isNullOrEmpty()) return null
val withContent = streams.filter { it.content?.isNotBlank() == true }
val filtered = withContent.filter { heightOf(it.getResolution()) <= maxRes }
val pool = filtered.ifEmpty { withContent }
return pool.maxByOrNull { it.bitrate ?: 0 }?.content
}
val combined = pickVideo(info.videoStreams)
val videoOnly = pickVideo(info.videoOnlyStreams)
val audioOnly = info.audioStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
_ui.value = PlayerUiState(
loading = false,
resolved = ResolvedPlayback(
title = info.name ?: "",
videoUrl = videoOnly,
audioUrl = audioOnly,
combinedUrl = combined,
dashMpdUrl = info.dashMpdUrl?.takeIf { it.isNotBlank() },
hlsUrl = info.hlsUrl?.takeIf { it.isNotBlank() },
segments = segments,
),
)
} catch (t: Throwable) {
_ui.value = PlayerUiState(
loading = false,
error = t.message ?: t.javaClass.simpleName,
)
}
}
}
}

View file

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Process-wide upcoming-videos queue. Mirrors the MediaController's
* MediaItem list 1:1 by index Position 0 is "currently playing",
* Position 1+ is "up next". Decoupled from the controller because:
*
* - The controller stores MediaItem (URL + Media3 metadata only).
* We need the original streamUrl, uploader, thumbnail, and
* SponsorBlock segments. NowPlayingItem carries all of that.
* - Media3's onMediaItemTransition fires when the engine auto-
* advances. PlaybackService listens, looks up the new index here,
* and pushes the resolved NowPlayingItem into NowPlaying so the
* minibar + SponsorBlock skip-loop reflect the new track.
*
* Append-only + setAll: no remove/reorder for v1. Mirrors how
* `addMediaItem` / `setMediaItem` mutate the controller. If we ever
* add a queue UI with drag-reorder, that'll need a sync layer.
*/
package com.sulkta.straw.feature.player
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
object Queue {
private val _items = MutableStateFlow<List<NowPlayingItem>>(emptyList())
val items: StateFlow<List<NowPlayingItem>> = _items.asStateFlow()
/** Replace the queue — used by setPlayingFrom when starting fresh. */
fun setAll(item: NowPlayingItem) {
_items.value = listOf(item)
}
fun append(item: NowPlayingItem) {
_items.update { it + item }
}
/**
* Insert at the given position (relative to the controller's
* indices). Used by "Play next" inserts right after the
* currently-playing item.
*/
fun insertAt(index: Int, item: NowPlayingItem) {
_items.update { current ->
val mut = current.toMutableList()
mut.add(index.coerceIn(0, mut.size), item)
mut.toList()
}
}
/** Read the item at the given controller index, or null on OOB. */
fun at(index: Int): NowPlayingItem? = _items.value.getOrNull(index)
fun clear() {
_items.value = emptyList()
}
}

View file

@ -0,0 +1,270 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Composable bridge to the PlaybackService MediaController.
*
* Why this file exists: every UI surface in Straw (inline player on the
* detail screen, the fullscreen Player, the minibar overlay) renders the
* same single underlying MediaController. We expose it via a
* CompositionLocal so the screens don't have to know how to connect.
*
* The controller is built async SessionToken bind happens on a
* background thread, the controller future resolves once the service is
* up. Until then `LocalStrawController.current` is null; consumers
* should render placeholder UI in that brief window.
*
* Lifecycle: tied to the activity's composition. When the activity
* finishes the DisposableEffect cleanup releases the future. The
* MediaSessionService stays alive iff there's still something playing
* (its own onTaskRemoved + STATE_ENDED logic handles that).
*
* Also: a small helper, [setPlayingFrom], that knows how to convert
* Straw's domain ResolvedPlayback (DASH URL / HLS URL / combined URL /
* video+audio pair) into a single MediaItem the service understands.
*/
package com.sulkta.straw.feature.player
import android.content.ComponentName
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.MoreExecutors
import com.sulkta.straw.data.Resume
import com.sulkta.straw.data.Settings
import com.sulkta.straw.feature.detail.ResolvedPlayback
import com.sulkta.straw.feature.detail.extractYtVideoId
val LocalStrawController = compositionLocalOf<MediaController?> { null }
@Composable
fun rememberStrawController(): MediaController? {
val context = LocalContext.current
val state = remember { mutableStateOf<MediaController?>(null) }
DisposableEffect(Unit) {
val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
val future = MediaController.Builder(context, token).buildAsync()
future.addListener({
// future.get() throws if the build failed; treat as null in that case.
state.value = runCatching { future.get() }.getOrNull()
}, MoreExecutors.directExecutor())
onDispose {
MediaController.releaseFuture(future)
state.value = null
}
}
return state.value
}
/**
* Push a resolved video into the controller and update NowPlaying.
*
* Stream-shape preference matches the previous activity-side picker:
* DASH (full quality + adaptive) > HLS > combined progressive > merged
* video+audio progressives > video-only progressive. The
* [StrawMediaSourceFactory] on the service end picks the right inner
* MediaSource based on MIME + the EXTRA_AUDIO_URL bundle.
*/
@UnstableApi
fun Player.setPlayingFrom(
streamUrl: String,
title: String,
uploader: String,
thumbnail: String?,
resolved: ResolvedPlayback,
startPositionMs: Long = 0L,
uploaderUrl: String? = null,
) {
val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return
val nowPlayingItem = NowPlayingItem(
streamUrl = streamUrl,
title = title,
uploader = uploader,
uploaderUrl = uploaderUrl,
thumbnail = thumbnail,
segments = resolved.segments,
)
// Atomic claim BEFORE any controller mutation. If a concurrent
// caller already set this URL (inline player + fullscreen Player
// racing each other on the same transition), we bail before
// double-priming the player. audit HIGH-C6.
val claimed = NowPlaying.claim(nowPlayingItem)
if (!claimed) return
// Replace the queue when starting fresh — Queue mirrors the
// controller's MediaItem list 1:1 by index. If the user later
// long-press-enqueues more items, append/insertAt keep them
// synced.
Queue.setAll(nowPlayingItem)
// Apply the user's max-resolution cap to DASH/HLS adaptive
// streams. — the cap previously only
// affected the videoOnly/combined picker; DASH manifests
// bypassed it because Media3 picked variants freely. setMaxVideoSize
// tells the ABR algorithm to never pick anything taller than
// ceiling. Auto = Int.MAX_VALUE = no constraint.
applyMaxResolutionCap()
// Auto-resume: when the caller passed the default 0L and
// Settings.autoResume is on, look up the saved scrub-point for
// this videoId. Lets the user pick up where they left off after
// an app update / process death. The store skips trivial
// positions and clears near-end so we don't auto-resume to 0:03
// or to the credits.
//
// Clamp the resume position against the RECORDED duration with a
// safety margin.: YouTube can replace a video
// at the same videoId with a shorter cut (live→VOD trim, premiere
// edit, channel replace) — without the clamp, setMediaItem seeks
// past the new end, ExoPlayer fires onPlayerError, the screen
// ends up stuck on the thumbnail+spinner (BUG-2 cascade).
val effectiveStart = if (startPositionMs == 0L && Settings.get().autoResume.value) {
val videoId = extractYtVideoId(streamUrl)
val saved = videoId?.let { Resume.get().get(it) }
if (saved == null) {
0L
} else {
val safeCeiling = saved.durationMs - 5_000L
if (saved.positionMs in 1L..safeCeiling) saved.positionMs else 0L
}
} else {
startPositionMs
}
setMediaItem(mediaItem, effectiveStart)
prepare()
playWhenReady = true
}
/**
* Push the current Settings.maxResolution into the controller's
* TrackSelectionParameters as a height cap. Idempotent safe to
* call repeatedly. Called inside setPlayingFrom so every new
* playback respects the live preference; setting changes mid-stream
* apply on next video.
*/
@UnstableApi
fun Player.applyMaxResolutionCap() {
val ceiling = Settings.get().maxResolution.value.ceiling
val maxHeight = if (ceiling >= Int.MAX_VALUE) Int.MAX_VALUE else ceiling
trackSelectionParameters = trackSelectionParameters.buildUpon()
.setMaxVideoSize(Int.MAX_VALUE, maxHeight)
.build()
}
/**
* Add a video to the playback queue right after the currently-playing
* item. If the player is idle (no current item), fall through to a
* setPlayingFrom that starts playback immediately. The caller already
* resolved playback (strawcore.streamInfo ResolvedPlayback).
*
* Returns true if the item was enqueued or started; false on a build
* failure (no playable stream in `resolved`).
*/
@UnstableApi
fun Player.enqueueNext(
streamUrl: String,
title: String,
uploader: String,
thumbnail: String?,
resolved: ResolvedPlayback,
uploaderUrl: String? = null,
): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, uploaderUrl, asNext = true)
/** Append to the back of the queue. Same idle-fallback as enqueueNext. */
@UnstableApi
fun Player.enqueueLast(
streamUrl: String,
title: String,
uploader: String,
thumbnail: String?,
resolved: ResolvedPlayback,
uploaderUrl: String? = null,
): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, uploaderUrl, asNext = false)
@UnstableApi
private fun Player.enqueueInternal(
streamUrl: String,
title: String,
uploader: String,
thumbnail: String?,
resolved: ResolvedPlayback,
uploaderUrl: String?,
asNext: Boolean,
): Boolean {
val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return false
val item = NowPlayingItem(
streamUrl = streamUrl,
title = title,
uploader = uploader,
uploaderUrl = uploaderUrl,
thumbnail = thumbnail,
segments = resolved.segments,
)
// Empty queue — there's nothing to "enqueue" onto. Treat as a
// start-playing-now and route through the normal claim path.
if (mediaItemCount == 0) {
setPlayingFrom(streamUrl, title, uploader, thumbnail, resolved, uploaderUrl = uploaderUrl)
return true
}
val insertIndex = if (asNext) currentMediaItemIndex + 1 else mediaItemCount
Queue.insertAt(insertIndex, item)
addMediaItem(insertIndex, mediaItem)
return true
}
@UnstableApi
private fun buildMediaItem(
title: String,
uploader: String,
thumbnail: String?,
r: ResolvedPlayback,
): MediaItem? {
val metadata = MediaMetadata.Builder()
.setTitle(title)
.setArtist(uploader)
.apply {
thumbnail?.let { setArtworkUri(android.net.Uri.parse(it)) }
}
.build()
val baseBuilder = MediaItem.Builder().setMediaMetadata(metadata)
return when {
!r.dashMpdUrl.isNullOrBlank() -> baseBuilder
.setUri(r.dashMpdUrl)
.setMimeType(MimeTypes.APPLICATION_MPD)
.build()
!r.hlsUrl.isNullOrBlank() -> baseBuilder
.setUri(r.hlsUrl)
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()
!r.combinedUrl.isNullOrBlank() -> baseBuilder
.setUri(r.combinedUrl)
.build()
!r.videoUrl.isNullOrBlank() && !r.audioUrl.isNullOrBlank() -> {
val extras = Bundle().apply {
putString(PlaybackService.EXTRA_AUDIO_URL, r.audioUrl)
}
baseBuilder
.setUri(r.videoUrl)
.setRequestMetadata(
MediaItem.RequestMetadata.Builder()
.setExtras(extras)
.build(),
)
.build()
}
!r.videoUrl.isNullOrBlank() -> baseBuilder
.setUri(r.videoUrl)
.build()
else -> null
}
}

View file

@ -0,0 +1,131 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Red progress bar painted across the bottom of a video thumbnail when
* the user has a saved scrub-point in ResumePositionsStore. Same shape
* YouTube + NewPipe use instantly readable as "you started this."
*
* Drops into any thumbnail-rendering Box; the caller is responsible for
* being inside a Box (so we can align to Bottom). Returns nothing when
* the videoId is blank or has no recorded position.
*/
package com.sulkta.straw.feature.player
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.sulkta.straw.OverlayDimColor
import com.sulkta.straw.ProgressBarFillColor
import com.sulkta.straw.ProgressBarTrackColor
import com.sulkta.straw.data.Resume
import com.sulkta.straw.feature.detail.extractYtVideoId
import com.sulkta.straw.util.formatDuration
/**
* Paint a 3dp watch-progress bar across the bottom of the surrounding
* Box when ResumePositionsStore has an entry for [videoId]. Silent
* no-op when there's no entry safe to call unconditionally.
*
* Must be used inside a Box (uses BoxScope.align). Caller's Box sets
* the thumbnail size; this composable just overlays the bar.
*/
@Composable
fun BoxScope.ThumbnailProgressOverlay(videoId: String?) {
if (videoId.isNullOrBlank()) return
// Plain collectAsState — collectAsStateWithLifecycle adds a
// DisposableEffect for lifecycle observation per call site, which
// adds up across 30 visible LazyColumn rows and contributes to
// scroll jank The Lifecycle pause optimization doesn't
// matter for a foreground feed that's only collected while the
// composable is on screen anyway.
//
// derivedStateOf isolates each row's
// dependency to ONLY its own videoId's entry. Without this, the
// 5s captureResumePosition tick re-emits the entire positions
// map → every visible thumbnail recomposes. With it, only rows
// whose specific entry changed recompose.
val positionsFlow = Resume.get().positions
val positions by positionsFlow.collectAsState()
val entry by remember(videoId) {
derivedStateOf { positions[videoId] }
}
val resolved = entry ?: return
if (resolved.durationMs <= 0L) return
val fraction = (resolved.positionMs.toFloat() / resolved.durationMs.toFloat())
.coerceIn(0f, 1f)
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(3.dp)
.background(ProgressBarTrackColor),
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(fraction)
.background(ProgressBarFillColor),
)
}
}
/**
* One-stop video thumbnail: 16:9 image with optional NewPipe-style
* duration pill at bottom-right + watch-progress overlay at bottom
* when the user has a saved scrub-point for [videoUrl].
*
* Pass an outer modifier with the desired width/height; the corner
* radius + clip are applied inside so the progress bar bleeds to the
* exact edge of the rounded thumbnail. durationSeconds <= 0 drops the
* badge (live streams, items that come back without a duration).
*/
@Composable
fun VideoThumbnail(
thumbnail: String?,
videoUrl: String?,
durationSeconds: Long,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.clip(RoundedCornerShape(6.dp))) {
AsyncImage(
model = thumbnail,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
if (durationSeconds > 0) {
Text(
text = formatDuration(durationSeconds),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(4.dp)
.clip(RoundedCornerShape(3.dp))
.background(OverlayDimColor)
.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
ThumbnailProgressOverlay(videoUrl?.let { extractYtVideoId(it) })
}
}

View file

@ -0,0 +1,269 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Two-screen unit for local playlists:
* PlaylistsScreen root list of all user playlists (drawer entry)
* PlaylistViewScreen items inside one playlist, tap to open
*/
package com.sulkta.straw.feature.playlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.sulkta.straw.feature.player.VideoThumbnail
import com.sulkta.straw.data.Playlists
import com.sulkta.straw.util.rememberBottomContentPadding
@Composable
fun PlaylistsScreen(
onOpenPlaylist: (id: String, name: String) -> Unit,
) {
val store = Playlists.get()
val playlists by store.playlists.collectAsState()
var showCreate by remember { mutableStateOf(false) }
var newName by remember { mutableStateOf("") }
var pendingDelete by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Playlists",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Button(onClick = { showCreate = true; newName = "" }) { Text("+ New") }
}
Spacer(modifier = Modifier.height(8.dp))
Text(
"${playlists.size} playlist${if (playlists.size == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
if (playlists.isEmpty()) {
Text(
"No playlists yet. Tap + New, or use the Save button on a video.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn(contentPadding = rememberBottomContentPadding()) {
items(playlists, key = { it.id }) { pl ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenPlaylist(pl.id, pl.name) }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.width(56.dp)
.height(56.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Text("📃")
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
pl.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
"${pl.items.size} video${if (pl.items.size == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(onClick = { pendingDelete = pl.id }) {
Text("Delete")
}
}
HorizontalDivider()
}
}
}
if (showCreate) {
AlertDialog(
onDismissRequest = { showCreate = false },
title = { Text("New playlist") },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
Button(onClick = {
store.create(newName)
showCreate = false
}) { Text("Create") }
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = { showCreate = false }) {
Text("Cancel")
}
},
)
}
pendingDelete?.let { id ->
val name = store.get(id)?.name ?: "this playlist"
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Delete \"$name\"?") },
text = { Text("This removes the playlist and its saved video references. Doesn't delete any downloaded files.") },
confirmButton = {
Button(onClick = {
store.delete(id)
pendingDelete = null
}) { Text("Delete") }
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = { pendingDelete = null }) {
Text("Cancel")
}
},
)
}
}
}
@Composable
fun PlaylistViewScreen(
playlistId: String,
initialName: String,
onOpenVideo: (url: String, title: String) -> Unit,
) {
val store = Playlists.get()
val playlists by store.playlists.collectAsState()
val playlist = playlists.firstOrNull { it.id == playlistId }
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
Text(
playlist?.name ?: initialName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
if (playlist == null) {
Text(
"Playlist not found.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
Text(
"${playlist.items.size} video${if (playlist.items.size == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
if (playlist.items.isEmpty()) {
Text(
"Empty. Tap Save on a video to add it.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn(contentPadding = rememberBottomContentPadding()) {
items(playlist.items, key = { it.streamUrl }) { item ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenVideo(item.streamUrl, item.title) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.Top,
) {
VideoThumbnail(
thumbnail = item.thumbnail,
videoUrl = item.streamUrl,
durationSeconds = 0L,
modifier = Modifier
.width(140.dp)
.height(80.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
item.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
item.uploader,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
androidx.compose.material3.TextButton(onClick = {
store.removeItem(playlist.id, item.streamUrl)
}) { Text("×") }
}
HorizontalDivider()
}
}
}
}
}

View file

@ -0,0 +1,329 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Shared long-press actions surface for video rows. The menu shows
* "Save to playlist" + "Share" (and Add-to-Queue later when the queue
* substrate lands). Every video row in the app Search results,
* Subs feed, Channel videos, History, Related calls
* `showVideoActions(...)` from a `combinedClickable.onLongClick`.
*
* Pure-Compose surface no ViewModel needed; PlaylistsStore is a
* process-wide singleton and the share Intent is a fire-and-forget
* Android system action.
*/
package com.sulkta.straw.feature.playlist
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.PlaylistPlay
import androidx.compose.material.icons.filled.QueueMusic
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
import com.sulkta.straw.StrawApp
import com.sulkta.straw.data.PlaylistItem
import com.sulkta.straw.data.Playlists
import com.sulkta.straw.feature.detail.resolveStreamPlayback
import com.sulkta.straw.feature.player.LocalStrawController
import com.sulkta.straw.feature.player.enqueueLast
import com.sulkta.straw.feature.player.enqueueNext
import com.sulkta.straw.util.runCatchingCancellable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Minimal video descriptor for the actions sheet. Avoids dragging
* the full search.StreamItem (which has extractor fields the
* actions don't need) so the same surface can be invoked from
* history rows where we only have a WatchHistoryItem.
*/
data class VideoActionTarget(
val streamUrl: String,
val title: String,
val uploader: String,
val thumbnail: String?,
)
@OptIn(ExperimentalMaterial3Api::class, UnstableApi::class)
@Composable
fun VideoActionsSheet(
target: VideoActionTarget,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val controller = LocalStrawController.current
// Use the process scope — rememberCoroutineScope dies when the
// sheet dismisses, and we dismiss the sheet BEFORE the strawcore
// network round-trip completes (so the user gets immediate
// feedback). Process scope keeps the in-flight resolve alive.
val sheetState = rememberModalBottomSheetState()
var showSaveDialog by remember { mutableStateOf(false) }
/** Resolve a streamUrl ResolvedPlayback + call the supplied
* enqueue method. Network resolution is the slow part; wrap it
* in runCatchingCancellable so the rememberCoroutineScope dying
* on sheet-dismiss propagates cleanly. */
fun enqueue(asNext: Boolean) {
val c = controller
if (c == null) {
Toast.makeText(context, "player not ready yet", Toast.LENGTH_SHORT).show()
return
}
// The action-sheet bypasses VideoDetailViewModel.load's
// allowlist gate — a long-press on a
// poisoned related-card otherwise hits strawcore directly.
if (!com.sulkta.straw.util.isAllowedYtUrl(target.streamUrl)) {
Toast.makeText(context, "unsupported URL", Toast.LENGTH_SHORT).show()
onDismiss()
return
}
Toast.makeText(context, "Resolving…", Toast.LENGTH_SHORT).show()
val appContext = context.applicationContext
onDismiss()
StrawApp.globalScope.launch {
runCatchingCancellable {
val info = uniffi.strawcore.streamInfo(target.streamUrl)
val resolved = resolveStreamPlayback(info)
withContext(Dispatchers.Main) {
val ok = if (asNext) {
c.enqueueNext(target.streamUrl, target.title, target.uploader, target.thumbnail, resolved)
} else {
c.enqueueLast(target.streamUrl, target.title, target.uploader, target.thumbnail, resolved)
}
val msg = if (ok) {
if (asNext) "Will play next" else "Added to queue"
} else {
"no playable stream"
}
Toast.makeText(appContext, msg, Toast.LENGTH_SHORT).show()
}
}.onFailure {
withContext(Dispatchers.Main) {
Toast.makeText(appContext, "queue failed", Toast.LENGTH_SHORT).show()
}
}
}
}
if (showSaveDialog) {
SaveToPlaylistDialog(
item = PlaylistItem(
streamUrl = target.streamUrl,
title = target.title,
thumbnail = target.thumbnail,
uploader = target.uploader,
),
onDismiss = {
showSaveDialog = false
onDismiss()
},
)
return
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
// Title row — truncated to one line, gives context for the
// actions below.
Text(
text = target.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 2,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
if (target.uploader.isNotBlank()) {
Text(
text = target.uploader,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 0.dp),
)
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
ActionRow(
icon = Icons.Filled.PlaylistPlay,
label = "Play next",
onClick = { enqueue(asNext = true) },
)
ActionRow(
icon = Icons.Filled.QueueMusic,
label = "Add to queue",
onClick = { enqueue(asNext = false) },
)
ActionRow(
icon = Icons.Filled.PlaylistAdd,
label = "Save to playlist",
onClick = { showSaveDialog = true },
)
ActionRow(
icon = Icons.Filled.Share,
label = "Share",
onClick = {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, target.streamUrl)
putExtra(Intent.EXTRA_SUBJECT, target.title)
}
context.startActivity(
Intent.createChooser(send, "Share video").addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK,
),
)
onDismiss()
},
)
}
}
}
@Composable
private fun ActionRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.width(16.dp))
Text(text = label, style = MaterialTheme.typography.bodyLarge)
}
}
/**
* Shared "Save to playlist" dialog was previously inline in
* VideoDetailScreen; promoted to its own file so the long-press
* menu on any row can reuse it.
*/
@Composable
fun SaveToPlaylistDialog(
item: PlaylistItem,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val store = Playlists.get()
val playlists by store.playlists.collectAsState()
var creatingNew by remember { mutableStateOf(false) }
var newName by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Save to playlist") },
text = {
Column {
if (playlists.isEmpty() && !creatingNew) {
Text(
"No playlists yet. Create one to save this video.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
}
playlists.forEach { pl ->
val already = pl.items.any { it.streamUrl == item.streamUrl }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !already) {
store.addItem(pl.id, item)
Toast.makeText(context, "saved to ${pl.name}", Toast.LENGTH_SHORT).show()
onDismiss()
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(if (already) "" else "", modifier = Modifier.width(28.dp))
Column(modifier = Modifier.weight(1f)) {
Text(pl.name, style = MaterialTheme.typography.bodyLarge)
Text(
"${pl.items.size} video${if (pl.items.size == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
HorizontalDivider()
}
if (creatingNew) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("New playlist name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val pl = store.create(newName)
store.addItem(pl.id, item)
Toast.makeText(context, "created ${pl.name} + saved", Toast.LENGTH_SHORT).show()
onDismiss()
}) { Text("Create + save") }
OutlinedButton(onClick = { creatingNew = false; newName = "" }) {
Text("Cancel")
}
}
} else {
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = { creatingNew = true }) {
Text("+ New playlist")
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Close") }
},
)
}

View file

@ -5,7 +5,9 @@
package com.sulkta.straw.feature.search
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -43,17 +45,30 @@ import androidx.compose.runtime.collectAsState
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.sulkta.straw.feature.player.VideoThumbnail
import com.sulkta.straw.data.History
import com.sulkta.straw.feature.playlist.VideoActionTarget
import com.sulkta.straw.feature.playlist.VideoActionsSheet
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.sulkta.straw.util.formatDuration
import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.rememberBottomContentPadding
@Composable
fun SearchScreen(
onOpenVideo: (url: String, title: String) -> Unit,
onOpenChannel: (url: String, name: String) -> Unit,
vm: SearchViewModel = viewModel(),
) {
val state by vm.ui.collectAsStateWithLifecycle()
val recentSearches by History.get().searches.collectAsState()
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
actionTarget?.let { target ->
VideoActionsSheet(target = target, onDismiss = { actionTarget = null })
}
Column(modifier = Modifier.fillMaxSize().statusBarsPadding().padding(16.dp)) {
OutlinedTextField(
@ -69,12 +84,19 @@ fun SearchScreen(
Spacer(modifier = Modifier.height(12.dp))
when {
state.loading -> Box(
// Loading WITH cached results: thin progress bar above the
// list, results stay visible. audit B-1 — the prior
// order short-circuited to a centered spinner and hid the
// cached preview the VM was trying to show.
state.loading && state.results.isEmpty() -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
state.error != null -> Box(
// Error WITH cached results: thin error banner above the
// list. Audit B-2 — error branch used to clobber the
// cached preview the VM explicitly kept.
state.error != null && state.results.isEmpty() -> Box(
modifier = Modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center,
) {
@ -116,32 +138,82 @@ fun SearchScreen(
contentAlignment = Alignment.Center,
) { Text("hit enter to search") }
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.results) { item ->
ResultRow(item = item) { onOpenVideo(item.url, item.title) }
HorizontalDivider()
else -> Column(modifier = Modifier.fillMaxSize()) {
if (state.loading) {
androidx.compose.material3.LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(4.dp))
}
if (state.error != null) {
Text(
text = "refresh failed: ${state.error}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 4.dp),
)
}
if (state.fromCache) {
Text(
text = if (state.loading) "Cached results · refreshing…"
else "Cached results · hit Search for fresh",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp),
)
}
val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState()
val filteredResults = remember(state.results, hideShorts) {
com.sulkta.straw.util.applyContentFilters(state.results, hideShorts = hideShorts)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = rememberBottomContentPadding(),
) {
items(filteredResults) { item ->
ResultRow(
item = item,
onClick = { onOpenVideo(item.url, item.title) },
onLongClick = {
actionTarget = VideoActionTarget(
streamUrl = item.url,
title = item.title,
uploader = item.uploader,
thumbnail = item.thumbnail,
)
},
onChannelClick = { url -> onOpenChannel(url, item.uploader) },
)
HorizontalDivider()
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ResultRow(item: StreamItem, onClick: () -> Unit) {
private fun ResultRow(
item: StreamItem,
onClick: () -> Unit,
onLongClick: () -> Unit,
onChannelClick: (url: String) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
AsyncImage(
model = item.thumbnail,
contentDescription = null,
VideoThumbnail(
thumbnail = item.thumbnail,
videoUrl = item.url,
durationSeconds = item.durationSeconds,
modifier = Modifier
.width(160.dp)
.height(90.dp)
.clip(RoundedCornerShape(6.dp)),
.height(90.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
@ -153,23 +225,49 @@ private fun ResultRow(item: StreamItem, onClick: () -> Unit) {
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(4.dp))
// Uploader on its own line — larger + tinted + clickable
// when we have a uploaderUrl to route to. Tapping the
// name jumps to the Channel screen; tapping anywhere else
// on the row still opens the video. Child clickable
// consumes the press before the row's clickable hears it.
val uploaderUrl = item.uploaderUrl
Text(
text = buildString {
append(item.uploader)
if (item.viewCount > 0) {
append(" · ")
append(formatViews(item.viewCount))
}
if (item.durationSeconds > 0) {
append(" · ")
append(formatDuration(item.durationSeconds))
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = item.uploader,
style = MaterialTheme.typography.bodyMedium,
color = if (!uploaderUrl.isNullOrBlank())
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = if (!uploaderUrl.isNullOrBlank())
Modifier
.clickable { onChannelClick(uploaderUrl) }
.padding(vertical = 4.dp)
else
Modifier.padding(vertical = 4.dp),
)
// Drop the duration here — VideoThumbnail's badge already
// renders it on the bottom-right of the thumbnail. Add the
// upload date instead so search results read like YT's
// own format. caught with the
// channel-page + related-row consistency pass.
val meta = buildString {
if (item.viewCount > 0) append(formatViews(item.viewCount))
if (item.uploadDateRelative.isNotBlank()) {
if (isNotEmpty()) append(" · ")
append(item.uploadDateRelative)
}
}
if (meta.isNotEmpty()) {
Text(
text = meta,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}

View file

@ -7,26 +7,35 @@ package com.sulkta.straw.feature.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.History
import com.sulkta.straw.util.bestThumbnail
import com.sulkta.straw.data.SearchCache
import com.sulkta.straw.data.Settings
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.search.SearchInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class SearchUiState(
val query: String = "",
val results: List<StreamItem> = emptyList(),
val loading: Boolean = false,
val error: String? = null,
/**
* True when the visible results came from the local cache and we
* have not yet replaced them with a network response. Lets the UI
* show a faint "from cache" hint without blocking the list.
*/
val fromCache: Boolean = false,
)
@kotlinx.serialization.Serializable
data class StreamItem(
val url: String,
val title: String,
@ -35,50 +44,211 @@ data class StreamItem(
val thumbnail: String?,
val durationSeconds: Long,
val viewCount: Long,
/** "2 days ago" / "3 weeks ago" / empty if not extracted. */
val uploadDateRelative: String = "",
)
class SearchViewModel : ViewModel() {
private val _ui = MutableStateFlow(SearchUiState())
val ui: StateFlow<SearchUiState> = _ui.asStateFlow()
/**
* In-memory snapshot of the disk corpus (saved search results +
* subs feed cache) for reactive filtering. Hydrated on
* Dispatchers.IO once at VM construction and refreshed after a
* successful submit.-C1 the previous
* implementation hit SharedPreferences + JSON-decoded ~225 KB on
* every keystroke, blocking the main thread.
*
* Plain @Volatile not StateFlow because nothing observes it
* ( the StateFlow synchronization buys
* nothing here).
*/
@Volatile
private var pool: List<StreamItem> = emptyList()
init {
rebuildPool()
}
/**
* Re-read both caches off the main thread and replace the pool
* snapshot. Called at construction and from Settings when the
* cache toggle flips ON (so a re-enable picks up freshly-seeded
* entries from a subsequent submit/refresh without waiting for
* process death). audit B2/Q10.
*/
fun rebuildPool() {
viewModelScope.launch {
pool = if (Settings.get().cacheEnabled.value) {
withContext(Dispatchers.IO) { buildPool() }
} else {
emptyList()
}
}
}
private fun buildPool(): List<StreamItem> = buildList {
runCatching { SearchCache.get().load().forEach { addAll(it.items) } }
runCatching { FeedCache.get().load().values.forEach { addAll(it.items) } }
}.distinctBy { it.url }
// Track the active submit so a fresh tap of Search cancels the
// previous network call rather than racing it.
// `_ui.value = _ui.value.copy()` patterns + concurrent
// submits were both lost-write hazards.
//
// Fence by Job identity, not query string.:
// `onQueryChange` mutates _ui.value.query for reactive filtering
// WITHOUT cancelling inFlight, so a string-equality fence treats
// a still-valid result as stale just because the user kept typing
// after submit. Job identity captures the "I am the active
// submit" intent — only a fresh submit cancels me.
private var inFlight: Job? = null
fun onQueryChange(q: String) {
_ui.value = _ui.value.copy(query = q)
// Clear any prior error state when the user resumes typing —
// a failed submit's banner used to persist into the next
// reactive preview, looking like the new query had failed.
// audit Q3.
_ui.update { it.copy(query = q, error = null) }
if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
val matches = reactiveFilter(q.trim())
if (matches.isNotEmpty()) {
_ui.update {
it.copy(
results = matches,
fromCache = true,
loading = false,
)
}
} else if (_ui.value.fromCache) {
_ui.update { it.copy(results = emptyList(), fromCache = false) }
}
} else if (q.isBlank()) {
_ui.update { it.copy(results = emptyList(), fromCache = false) }
}
}
fun submit() {
val q = _ui.value.query.trim()
if (q.isEmpty()) return
runCatching { History.get().recordSearch(q) }
_ui.value = _ui.value.copy(loading = true, error = null, results = emptyList())
viewModelScope.launch {
try {
val items = withContext(Dispatchers.IO) { search(q) }
_ui.value = _ui.value.copy(loading = false, results = items)
} catch (t: Throwable) {
_ui.value = _ui.value.copy(
loading = false,
error = t.message ?: t.javaClass.simpleName,
// Cache hit on submit: show immediately, kick off refresh
// behind it. audit B3 — the previous shape called
// `SearchCache.get().load()` on the main thread, doing the
// exact ~150 KB JSON decode the reactive-filter fix was
// supposed to eliminate. Now uses the StateFlow snapshot.
val cached = if (Settings.get().cacheEnabled.value) {
SearchCache.get().entries.value
.firstOrNull { it.query.equals(q, ignoreCase = true) }
?.items
} else null
// Cancel any prior in-flight submit BEFORE writing the cached
// preview to the UI —: previously a fresh
// submit that hit the cache could be clobbered seconds later
// by the prior submit's late terminal write, because the
// prior coroutine had already advanced past its `ensureActive`
// gate by the time the new submit got around to cancelling.
inFlight?.cancel()
if (cached != null && cached.isNotEmpty()) {
_ui.update {
it.copy(
loading = true,
error = null,
results = cached,
fromCache = true,
)
}
} else {
_ui.update {
it.copy(
loading = true,
error = null,
results = emptyList(),
fromCache = false,
)
}
}
inFlight = viewModelScope.launch {
try {
// strawcore.search() is suspend on the tokio runtime baked
// into libstrawcore.so — no Dispatchers.IO wrap needed.
val rustItems = uniffi.strawcore.search(q)
val items = rustItems.map { r ->
StreamItem(
url = r.url,
title = r.title.ifBlank { "(no title)" },
uploader = r.uploader,
uploaderUrl = r.uploaderUrl,
thumbnail = r.thumbnail,
durationSeconds = r.durationSeconds,
viewCount = r.viewCount,
uploadDateRelative = r.uploadDateRelative,
)
}
// Fence by job identity (ensureActive) — only a fresh
// submit that called inFlight?.cancel() invalidates
// me. Bare typing in the search bar (onQueryChange)
// doesn't cancel anything, so our results are still
// valid even if `_ui.value.query` moved on.
ensureActive()
_ui.update {
it.copy(
loading = false,
results = items,
fromCache = false,
)
}
// Record AFTER the search succeeds so mistyped queries
// that error out don't pollute the recent-searches list.
runCatching { History.get().recordSearch(q) }
if (Settings.get().cacheEnabled.value) {
withContext(Dispatchers.IO) {
// Re-check active state after the dispatcher
// switch (still cooperative cancellation).
ensureActive()
runCatching { SearchCache.get().record(q, items) }
// Refresh the in-memory pool with the new
// entries so subsequent reactive filters see
// them without waiting for a process restart.
pool = buildPool()
}
}
} catch (t: Throwable) {
if (t is CancellationException) throw t
// Keep the cached preview visible on network failure so
// the user still has something to look at while offline.
_ui.update {
it.copy(
loading = false,
error = com.sulkta.straw.util.LogDump.scrubLine(
t.message ?: t.javaClass.simpleName,
),
)
}
}
}
}
private fun search(query: String): List<StreamItem> {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val qh = service.searchQHFactory.fromQuery(query, emptyList(), "")
val info = SearchInfo.getInfo(service, qh)
return info.relatedItems
.filterIsInstance<StreamInfoItem>()
.map {
StreamItem(
url = it.url,
title = it.name ?: "(no title)",
uploader = it.uploaderName ?: "",
uploaderUrl = it.uploaderUrl,
thumbnail = bestThumbnail(it.thumbnails),
durationSeconds = it.duration,
viewCount = it.viewCount,
)
/**
* Walk the in-memory `pool` and return items whose title or uploader
* contains the query. Case-insensitive, capped at 60 results.
* No disk I/O on the hot path `pool` is refreshed off-thread
* after each successful submit and at VM construction.
*/
private fun reactiveFilter(q: String): List<StreamItem> {
// contains(ignoreCase=true) on the raw fields avoids the
// 3N+ String allocations per keystroke that `.lowercase()`
// copy-and-compare produced.
return pool.asSequence()
.filter { item ->
item.title.contains(q, ignoreCase = true)
|| item.uploader.contains(q, ignoreCase = true)
}
.take(60)
.toList()
}
}

View file

@ -5,46 +5,103 @@
package com.sulkta.straw.feature.settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import com.sulkta.straw.feature.player.NowPlaying
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.collectAsState
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import android.widget.Toast
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.material3.FilterChip
import com.sulkta.straw.BuildConfig
import com.sulkta.straw.data.AutoUpdateInterval
import com.sulkta.straw.data.BgFeedRefreshInterval
import com.sulkta.straw.data.CacheCap
import com.sulkta.straw.data.CacheTtl
import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.Resume
import com.sulkta.straw.feature.feed.FeedRefreshScheduler
import com.sulkta.straw.feature.update.UpdateScheduler
import com.sulkta.straw.feature.update.runUpdateCheck
import com.sulkta.straw.util.formatRelativeSince
import com.sulkta.straw.data.History
import com.sulkta.straw.data.AutoplayMode
import com.sulkta.straw.data.MaxResolution
import com.sulkta.straw.data.SbCategory
import com.sulkta.straw.data.SearchCache
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.ThemeMode
import com.sulkta.straw.feature.dataimport.ImportResult
import com.sulkta.straw.feature.dataimport.SettingsImport
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
import com.sulkta.straw.feature.search.SearchViewModel
import com.sulkta.straw.util.LogDump
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@Composable
fun SettingsScreen() {
val store = Settings.get()
val cats by store.sbCategories.collectAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
var importRunning by remember { mutableStateOf(false) }
var importResult by remember { mutableStateOf<Result<ImportResult>?>(null) }
val pickZip = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
importRunning = true
scope.launch {
importResult = SettingsImport.run(context, uri)
importRunning = false
}
}
// Clear the gesture-bar / 3-button nav bar at the bottom and add
// extra room for the minibar overlay when something's playing —
// otherwise the bottom rows of Settings render UNDER both. The
// minibar is a process-wide BottomCenter overlay (StrawActivity
// ScreenContent) so each scrolling screen has to leave its own gap.
val showingMinibar by NowPlaying.current.collectAsState()
val minibarReserve = if (showingMinibar != null) 72.dp else 0.dp
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
) {
@ -109,6 +166,533 @@ fun SettingsScreen() {
HorizontalDivider()
}
Spacer(modifier = Modifier.height(32.dp))
Text(
"Appearance",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Light, dark, or follow the system setting.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
val theme by store.themeMode.collectAsState()
ThemeMode.entries.forEach { t ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { store.setThemeMode(t) }
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (t == theme) "${t.label}" else " ${t.label}",
style = MaterialTheme.typography.bodyLarge,
color = if (t == theme) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
)
}
HorizontalDivider()
}
Spacer(modifier = Modifier.height(32.dp))
Text(
"Autoplay",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"When a video ends with nothing left in the queue, what should " +
"play next? Queue auto-advance always works regardless of " +
"this setting — autoplay only kicks in at the end.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
val autoplayMode by store.autoplayMode.collectAsState()
AutoplayMode.entries.forEach { m ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { store.setAutoplayMode(m) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (m == autoplayMode) "${m.label}" else " ${m.label}",
style = MaterialTheme.typography.bodyLarge,
color = if (m == autoplayMode) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
)
}
Text(
m.help,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 24.dp, bottom = 4.dp),
)
HorizontalDivider()
}
Spacer(modifier = Modifier.height(12.dp))
val skipWatched by store.autoplaySkipWatched.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Skip already-watched videos",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
"Autoplay picks the next un-watched video.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = skipWatched,
onCheckedChange = { store.setAutoplaySkipWatched(it) },
)
}
val autoStartPlayback by store.autoStartPlayback.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Auto-start playback",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
"Open a video → it starts immediately. Off: tap " +
"the thumbnail to start.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = autoStartPlayback,
onCheckedChange = { store.setAutoStartPlayback(it) },
)
}
val pauseOnHeadphones by store.pauseOnHeadphoneDisconnect.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Pause on headphone disconnect",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
"Wired pull / Bluetooth drop → pause instead of " +
"switching to the phone speaker.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = pauseOnHeadphones,
onCheckedChange = { store.setPauseOnHeadphoneDisconnect(it) },
)
}
val autoResume by store.autoResume.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Resume where you left off",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
"Reopen a video → pick up at the saved scrub-point. " +
"Off: every open starts at 0:00.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = autoResume,
onCheckedChange = { store.setAutoResume(it) },
)
}
val hideShorts by store.hideShorts.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Hide Shorts",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
"Drop /shorts/ URLs from search + channel pages " +
"and best-effort filter (\"#shorts\" tag) on the " +
"subs feed.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = hideShorts,
onCheckedChange = { store.setHideShorts(it) },
)
}
Spacer(modifier = Modifier.height(32.dp))
Text(
"App updates",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Polls fdroid.sulkta.com for newer Straw builds. When one's " +
"available, a notification taps through to the system " +
"installer. NewPipe's silent-staleness problem, solved.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
val autoUpdateCheck by store.autoUpdateCheck.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Check for updates",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
"Background poll. Tap the notification to install.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = autoUpdateCheck,
onCheckedChange = { checked ->
store.setAutoUpdateCheck(checked)
UpdateScheduler.applyFromSettings(context)
},
)
}
if (autoUpdateCheck) {
val interval by store.autoUpdateInterval.collectAsState()
Text(
"Interval",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 8.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AutoUpdateInterval.entries.forEach { opt ->
FilterChip(
selected = interval == opt,
onClick = {
store.setAutoUpdateInterval(opt)
UpdateScheduler.applyFromSettings(context)
},
label = { Text(opt.label) },
)
}
}
}
val lastCheckMs by store.lastUpdateCheckMs.collectAsState()
val latestVc by store.latestKnownVc.collectAsState()
val latestVname by store.latestKnownVname.collectAsState()
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
val lastText = if (lastCheckMs <= 0L) {
"Never checked."
} else {
"Last checked: ${formatRelativeSince(lastCheckMs)}."
}
Text(
lastText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (latestVc > 0L && latestVc > BuildConfig.VERSION_CODE) {
val label = latestVname.ifBlank { "vc=$latestVc" }
Text(
"Update available: $label.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
}
}
TextButton(
onClick = {
scope.launch {
withContext(Dispatchers.IO) { runUpdateCheck(context) }
}
},
) { Text("Check now") }
}
Spacer(modifier = Modifier.height(32.dp))
Text(
"Local cache",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Caches the subs feed and recent searches on disk so the app " +
"paints instantly on cold start and you can search " +
"previously-seen videos with no network. ~400 KB max. " +
"Turn it off to save space on low-storage devices.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
val cacheEnabled by store.cacheEnabled.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
"Enable local cache",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
val feedVm: SubscriptionFeedViewModel = viewModel()
val searchVm: SearchViewModel = viewModel()
Switch(
checked = cacheEnabled,
onCheckedChange = { checked ->
store.setCacheEnabled(checked)
scope.launch {
if (!checked) {
withContext(Dispatchers.IO) {
runCatching { FeedCache.get().clear() }
runCatching { SearchCache.get().clear() }
}
feedVm.clearInMemoryCache()
// Drop the in-memory reactive-search pool
// too — without this, typing into Search
// still surfaces hits from the just-wiped
// disk cache.
searchVm.rebuildPool()
} else {
// Cache re-enabled: trigger a real refresh
// so the feed repopulates without waiting
// for the user to navigate away and back.
// audit B2.
feedVm.refresh()
searchVm.rebuildPool()
}
}
},
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
"Background refresh",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Periodically pre-fetch the subs feed so the next time you " +
"open Straw the latest videos are already there. Off by " +
"default (battery cost on cell).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
val bgEnabled by store.bgFeedRefreshEnabled.collectAsState()
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
"Auto-refresh subs",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Switch(
checked = bgEnabled,
onCheckedChange = { checked ->
store.setBgFeedRefreshEnabled(checked)
FeedRefreshScheduler.applyFromSettings(context)
},
)
}
if (bgEnabled) {
val bgInterval by store.bgFeedRefreshInterval.collectAsState()
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
BgFeedRefreshInterval.entries.forEach { opt ->
FilterChip(
selected = bgInterval == opt,
onClick = {
store.setBgFeedRefreshInterval(opt)
FeedRefreshScheduler.applyFromSettings(context)
},
label = { Text(opt.label) },
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
"Cache & history limits",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Pick how much to keep. Unlimited = no auto-pruning. Old " +
"entries beyond a TTL are dropped on read.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
// Sample on-disk usage once per Settings entry — File.length() is
// cheap but we don't need it to recompose on every state change.
// remember keeps the same snapshot for the entire session.
val usage = remember {
object {
val history = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_history")
val resume = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_resume_positions")
val search = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_search_cache")
val feed = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_feed_cache")
val coil = com.sulkta.straw.util.StorageUsage.coilDiskCacheBytes(context)
}
}
CacheCapRow(
label = "Watch + search history",
selected = store.historyWatchesCap.collectAsState().value,
onPick = { store.setHistoryWatchesCap(it) },
usageBytes = usage.history,
)
CacheCapRow(
label = "Search history",
selected = store.historySearchesCap.collectAsState().value,
onPick = { store.setHistorySearchesCap(it) },
)
CacheCapRow(
label = "Resume positions",
selected = store.resumePositionsCap.collectAsState().value,
onPick = { store.setResumePositionsCap(it) },
usageBytes = usage.resume,
)
CacheCapRow(
label = "Search results cache",
selected = store.searchCacheCap.collectAsState().value,
onPick = { store.setSearchCacheCap(it) },
usageBytes = usage.search,
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Subs feed cache",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Text(
text = "Used: ${com.sulkta.straw.util.StorageUsage.format(usage.feed)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Image cache (thumbnails)",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Text(
text = "Used: ${com.sulkta.straw.util.StorageUsage.format(usage.coil)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
"Cache TTL",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
"Drop subs feed + search cache entries older than this.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val ttl by store.cacheTtl.collectAsState()
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CacheTtl.entries.forEach { opt ->
FilterChip(
selected = ttl == opt,
onClick = { store.setCacheTtl(opt) },
label = { Text(opt.label) },
)
}
}
Spacer(modifier = Modifier.height(32.dp))
Text(
"History",
@ -124,6 +708,113 @@ fun SettingsScreen() {
Text("Clear searches")
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = {
scope.launch {
withContext(Dispatchers.IO) {
runCatching { FeedCache.get().clear() }
runCatching { SearchCache.get().clear() }
runCatching { Resume.get().clearAll() }
runCatching { History.get().clearWatches() }
runCatching { History.get().clearSearches() }
}
}
},
) { Text("Clear all caches") }
Spacer(modifier = Modifier.height(32.dp))
Text(
"Diagnostics",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Dump this app's recent logcat to a text file and open the " +
"system share sheet — attach it when reporting an issue.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
var logDumping by remember { mutableStateOf(false) }
OutlinedButton(
enabled = !logDumping,
onClick = {
logDumping = true
scope.launch {
val outcome = LogDump.capture(context)
logDumping = false
outcome.onSuccess { intent ->
context.startActivity(
android.content.Intent.createChooser(intent, "Share Straw logs"),
)
}
outcome.onFailure { t ->
Toast.makeText(
context,
"Log dump failed: ${t.message ?: t.javaClass.simpleName}",
Toast.LENGTH_LONG,
).show()
}
}
},
) {
Text(if (logDumping) "Exporting…" else "Export logs…")
}
Spacer(modifier = Modifier.height(32.dp))
Text(
"Import from NewPipe / Tubular",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Pick a NewPipeData-*.zip or TubularData-*.zip — we'll lift " +
"your subscriptions, playlists, search history, watch history " +
"(capped to 50 most recent), and a curated subset of settings. " +
"Other services (SoundCloud, PeerTube) are skipped.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
Button(
enabled = !importRunning,
onClick = { pickZip.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) },
) {
if (importRunning) {
CircularProgressIndicator(
modifier = Modifier.height(18.dp).padding(end = 8.dp),
strokeWidth = 2.dp,
)
Text("Importing…")
} else {
Text("Pick export file…")
}
}
// Tail spacer to clear the minibar overlay when something's
// playing. Without this the last Settings row gets eaten by
// the 64dp BottomCenter chip.
Spacer(modifier = Modifier.height(minibarReserve))
}
importResult?.let { res ->
AlertDialog(
onDismissRequest = { importResult = null },
title = { Text(if (res.isSuccess) "Import complete" else "Import failed") },
text = {
val body = res.fold(
onSuccess = { it.summary() },
onFailure = { it.message ?: it.javaClass.simpleName },
)
Text(body, style = MaterialTheme.typography.bodyMedium)
},
confirmButton = {
TextButton(onClick = { importResult = null }) { Text("OK") }
},
)
}
}
@ -152,3 +843,46 @@ private fun CategoryRow(
Switch(checked = enabled, onCheckedChange = { onToggle() })
}
}
/**
* Compact chip-group row for picking a CacheCap. Label on the left,
* 5 chips on the right, optional "Used: X KB" suffix to the right
* of the label so the user can see what each cap is doing.
*/
@Composable
private fun CacheCapRow(
label: String,
selected: CacheCap,
onPick: (CacheCap) -> Unit,
usageBytes: Long = 0L,
) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
if (usageBytes > 0L) {
Text(
text = "Used: ${com.sulkta.straw.util.StorageUsage.format(usageBytes)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
CacheCap.entries.forEach { opt ->
FilterChip(
selected = selected == opt,
onClick = { onPick(opt) },
label = { Text(opt.label) },
)
}
}
}
}

View file

@ -0,0 +1,145 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Poll Sulkta's F-Droid repo for a newer Straw build. Returns the
* highest versionCode + the APK download URL so the worker can post
* a notification.
*
* F-Droid's index-v2.json is the canonical machine-readable shape; we
* parse just the subset we care about (versions.* manifest.versionCode
* + file.name). `ignoreUnknownKeys` keeps us forward-compat with new
* fields fdroidserver may add later.
*/
package com.sulkta.straw.feature.update
import com.sulkta.straw.BuildConfig
import com.sulkta.straw.util.runCatchingCancellable
import com.sulkta.straw.util.strawLogW
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
private const val INDEX_HOST = "fdroid.sulkta.com"
private const val INDEX_URL = "https://fdroid.sulkta.com/fdroid/repo/index-v2.json"
private const val REPO_BASE = "https://fdroid.sulkta.com/fdroid/repo"
/**
* Only accept file names that look like a plain APK basename. The index
* controls a string we substitute into an `ACTION_VIEW` intent; without
* sanitization a hostile or compromised index could ship `..//host/x.apk`
* or worse.
*/
private val APK_NAME_RE = Regex("""^/[A-Za-z0-9._-]+\.apk$""")
/**
* Sanity-cap on parsed versionCode. Straw vc is currently low double
* digits; ten million is a horizon we won't hit organically but blocks
* a hostile index from latching us to Long.MAX_VALUE and burying every
* legitimate update behind a "you're already up to date" check.
*/
private const val MAX_PLAUSIBLE_VC = 10_000_000L
data class UpdateInfo(
val versionCode: Long,
val versionName: String,
val apkUrl: String,
)
object AppUpdateClient {
/**
* Pin two Subject-Public-Key-Info SHA-256 hashes against
* fdroid.sulkta.com so an off-tree CA misissue can't ship the
* user an attacker-signed index.
*
* - sha256/8ofd... current leaf SPKI. Rotates every ~90 days
* with each Let's Encrypt renewal; an app update before the
* next rotation refreshes this pin.
* - sha256/y7xV... Let's Encrypt E7 intermediate SPKI. Stable
* for years; serves as the rotation-safety pin while we push
* a new leaf hash.
*
* When the leaf pin no longer matches (post-rotation), OkHttp
* still accepts the chain because the E7 intermediate pin
* matches. The next app release rolls the leaf forward.
*/
private val pinner: CertificatePinner = CertificatePinner.Builder()
.add(INDEX_HOST, "sha256/8ofdiPS6TAiUx9zb2O7Qa9IKZQ3D2i+18teKCrz/MqA=")
.add(INDEX_HOST, "sha256/y7xVm0TVJNahMr2sZydE2jQH8SquXV9yLF9seROHHHU=")
.build()
private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.certificatePinner(pinner)
.build()
private val json = Json { ignoreUnknownKeys = true }
/**
* Fetch + parse the repo index, return the highest-versionCode entry
* for THIS app's package. Returns null on network/parse failure (the
* caller treats null as "no update available, try again later").
*/
suspend fun fetchLatest(): UpdateInfo? = withContext(Dispatchers.IO) {
runCatchingCancellable {
val req = Request.Builder().url(INDEX_URL).build()
val raw = http.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@runCatchingCancellable null
resp.body.string()
}
val index = json.decodeFromString<FdroidIndex>(raw)
val pkg = index.packages[BuildConfig.APPLICATION_ID]
?: return@runCatchingCancellable null
val best = pkg.versions.values
.maxByOrNull { it.manifest.versionCode }
?: return@runCatchingCancellable null
// Reject implausible versionCodes outright — see
// MAX_PLAUSIBLE_VC.
if (best.manifest.versionCode <= 0 ||
best.manifest.versionCode > MAX_PLAUSIBLE_VC) {
strawLogW("StrawUpdate") {
"rejecting implausible versionCode=${best.manifest.versionCode}"
}
return@runCatchingCancellable null
}
// Strict APK-basename match before we hand this off to
// ACTION_VIEW. Anything else gets logged + dropped.
val fileName = best.file.name
if (!APK_NAME_RE.matches(fileName)) {
strawLogW("StrawUpdate") {
"rejecting unsafe file.name=${fileName.take(80)}"
}
return@runCatchingCancellable null
}
UpdateInfo(
versionCode = best.manifest.versionCode,
versionName = best.manifest.versionName.orEmpty(),
apkUrl = "$REPO_BASE$fileName",
)
}.getOrNull()
}
}
@Serializable
private data class FdroidIndex(val packages: Map<String, FdroidPackage> = emptyMap())
@Serializable
private data class FdroidPackage(val versions: Map<String, FdroidVersion> = emptyMap())
@Serializable
private data class FdroidVersion(val file: FdroidFile, val manifest: FdroidManifest)
@Serializable
private data class FdroidFile(val name: String)
@Serializable
private data class FdroidManifest(
val versionCode: Long,
val versionName: String? = null,
)

View file

@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Periodic + on-demand self-update check. WorkManager fires this on the
* cadence the user picked in Settings; on cold start StrawApp also
* kicks one off so the user sees pending updates without waiting a full
* interval. The runner is small + bounded fetch index, compare, post
* notification, done.
*
* NewPipe's biggest UX gap is silent staleness: users sit on
* months-old builds because nothing tells them to update. This worker
* + the SettingsScreen toggle close that gap without trying to be a
* full updater (Android won't let a non-system app silent-install
* APKs anyway). Tap the notification ACTION_VIEW the APK URL the
* system handles download + install confirm.
*/
package com.sulkta.straw.feature.update
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.sulkta.straw.BuildConfig
import com.sulkta.straw.data.Settings
import com.sulkta.straw.util.strawLogI
/**
* Single source of truth for "did we find a newer version?" logic.
* Touched by both the scheduled worker AND the "Check now" Settings
* button so behavior stays identical regardless of trigger.
*/
suspend fun runUpdateCheck(context: Context): UpdateInfo? {
val info = AppUpdateClient.fetchLatest()
Settings.get().setLastUpdateCheck(System.currentTimeMillis())
if (info == null) {
strawLogI("update", "check: network/parse failure, will retry")
return null
}
if (info.versionCode <= BuildConfig.VERSION_CODE) {
strawLogI("update", "check: up to date (latest=${info.versionCode})")
Settings.get().setLatestKnownVersion(0L, "")
return null
}
strawLogI("update", "check: ${BuildConfig.VERSION_CODE}${info.versionCode} available")
Settings.get().setLatestKnownVersion(info.versionCode, info.versionName)
postUpdateNotification(context, info)
return info
}
class UpdateCheckWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
if (!Settings.get().autoUpdateCheck.value) return Result.success()
runUpdateCheck(applicationContext)
// Always succeed — a failed check just retries on the next
// scheduled tick. Retry-with-backoff would burn battery for no
// gain (the index is sticky and fdroid.sulkta.com is on Sulkta
// infra, not a third-party rate limiter).
return Result.success()
}
}
private const val NOTIF_CHANNEL_ID = "straw-update"
private const val NOTIF_ID = 23
private fun postUpdateNotification(context: Context, info: UpdateInfo) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
NOTIF_CHANNEL_ID,
"Straw updates",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Notifies when a newer Straw build is on fdroid.sulkta.com."
}
nm.createNotificationChannel(channel)
// ACTION_VIEW on the APK URL — Chrome / system browser fetches it
// via DownloadManager and the user taps it to install. No
// INSTALL_PACKAGES permission needed; the system installer handles
// the confirm dialog.
val viewIntent = Intent(Intent.ACTION_VIEW, Uri.parse(info.apkUrl))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pending = PendingIntent.getActivity(
context,
0,
viewIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val name = info.versionName.ifBlank { "vc=${info.versionCode}" }
val notif = NotificationCompat.Builder(context, NOTIF_CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle("Straw $name available")
.setContentText("Tap to download from fdroid.sulkta.com.")
.setAutoCancel(true)
.setContentIntent(pending)
.build()
nm.notify(NOTIF_ID, notif)
}

View file

@ -0,0 +1,66 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Wires the user's auto-update preferences into WorkManager. Called
* from StrawApp.onCreate (initial enqueue) and from SettingsScreen
* (re-apply when the toggle / interval flips).
*
* Uses unique-name + REPLACE so flipping the interval mid-flight just
* swaps the schedule instead of stacking workers.
*/
package com.sulkta.straw.feature.update
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.sulkta.straw.data.AutoUpdateInterval
import com.sulkta.straw.data.Settings
import java.util.concurrent.TimeUnit
private const val WORK_NAME = "straw-update-check"
object UpdateScheduler {
fun applyFromSettings(context: Context) {
val s = Settings.get()
val enabled = s.autoUpdateCheck.value
val interval = s.autoUpdateInterval.value
val wm = WorkManager.getInstance(context.applicationContext)
if (!enabled) {
wm.cancelUniqueWork(WORK_NAME)
return
}
// WorkManager floors periodic intervals at 15 minutes.
// coerceAtLeast(15) future-proofs against a smaller enum case
// landing without anyone noticing the silent clamp.
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(
interval.minutes.coerceAtLeast(15L),
TimeUnit.MINUTES,
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
).build()
wm.enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
}
}
/**
* Map the user-facing AutoUpdateInterval enum to minutes for
* WorkManager. WM enforces a 15-minute floor on periodic work; any
* value below that would silently be clamped.
*/
private val AutoUpdateInterval.minutes: Long
get() = when (this) {
AutoUpdateInterval.H1 -> 60
AutoUpdateInterval.H6 -> 6 * 60
AutoUpdateInterval.H24 -> 24 * 60
}

View file

@ -13,9 +13,36 @@
package com.sulkta.straw.net
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import okio.Buffer
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Path C-6 / Phase U-5: USER_AGENT + shared OkHttpClient that previously
* lived on NewPipeDownloader. After ripping NewPipeExtractor, the RYD +
* SponsorBlock + ExoPlayer HTTP factories still need both. One shared
* client is fine.
*/
const val STRAW_USER_AGENT: String =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Straw/1.0"
// OkHttpClient is internally thread-safe; lazy(SYNCHRONIZED) builds
// exactly once across threads. — the prior
// synchronized(STRAW_USER_AGENT) locked an interned String literal
// shared with any other code in any library that happened to lock
// the same literal. Lazy-delegate avoids the global pool lock.
private val sharedClient: OkHttpClient by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
}
fun strawHttpClient(): OkHttpClient = sharedClient
fun ResponseBody.cappedString(maxBytes: Long): String {
val cl = contentLength()

View file

@ -0,0 +1,176 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Path C-7 (post-audit): wrap DefaultHttpDataSource so each open() that
* lacks a bounded length turns into a sequence of bounded Range requests
* (default 1 MiB chunks).
*
* Background: rustypipe's iOS InnerTube client returns pre-signed
* googlevideo URLs. Those URLs reject an open-ended `Range: bytes=N-`
* with HTTP 403 they only accept bounded `Range: bytes=N-M`. ExoPlayer
* issues open-ended Range requests by default, which made every non-HLS
* iOS-origin video 403 on first byte. This shim makes ExoPlayer
* iOS-shaped without touching media-source selection.
*
* Verified via 2026-05-24 emulator audit (memory/audit-straw-vc16-emulator-2026-05-24.md):
* curl -r 0-1023 206 OK
* curl -H "Range: bytes=0-" 403 (any UA)
* curl no Range 403
*/
package com.sulkta.straw.net
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.HttpDataSource
import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW
private const val TAG = "IosSafeDS"
@UnstableApi
class IosSafeHttpDataSource(
private val inner: HttpDataSource,
private val chunkBytes: Long = DEFAULT_CHUNK_BYTES,
) : HttpDataSource by inner {
/** The original (caller-supplied) spec — kept so we can compute the next chunk. */
private var originalSpec: DataSpec? = null
/** How many bytes have been read since the caller's open(). */
private var totalRead: Long = 0
/** Bytes left in the current inner-open chunk. -1 = unknown end. */
private var chunkRemaining: Long = 0
override fun open(dataSpec: DataSpec): Long {
// When length is set, respect it but still cap the first inner-open to
// chunkBytes. When length is unset, request a chunk and we'll roll
// forward on subsequent reads.
val requestLen = if (dataSpec.length == C.LENGTH_UNSET.toLong()) {
chunkBytes
} else {
minOf(dataSpec.length, chunkBytes)
}
// NOTE: DataSpec.subrange(offset, length) ADDS offset to the existing
// position — so subrange(position, length) doubles the position. Use
// buildUpon().setLength(...) which preserves position and only bounds
// the byte length. This is what makes ExoPlayer's first Range header
// come out as `bytes=N-M` (closed, accepted by googlevideo iOS URLs)
// instead of `bytes=N-` (open, rejected with 403).
val bounded = dataSpec.buildUpon().setLength(requestLen).build()
// Surface itag + mime from query so we can tell video vs audio apart in
// logs. SECURITY: do NOT include the full URL — pre-signed googlevideo
// URLs contain session-bound credentials (signature, sig, pot, expire,
// cpn) that would otherwise ride a `LogDump.capture` straight into the
// user's share-sheet target. Host + itag is enough to debug from.
val u = dataSpec.uri
val itag = u.getQueryParameter("itag")
val mime = u.getQueryParameter("mime")
strawLogD(TAG) {
"open: host=${u.host} itag=$itag mime=$mime " +
"pos=${bounded.position} len=${bounded.length} " +
"(origLen=${dataSpec.length}, chunkBytes=$chunkBytes)"
}
originalSpec = dataSpec
totalRead = 0
// inner.open() returns the BOUNDED chunk's length. Track it so we
// know when to roll to the next chunk.
chunkRemaining = try {
inner.open(bounded)
} catch (t: Throwable) {
strawLogW(TAG, t) { "open failed: ${t.javaClass.simpleName}" }
throw t
}
strawLogD(TAG) { "open: inner chunkRemaining=$chunkRemaining" }
// Report the original (potentially unbounded) length to the caller —
// ExoPlayer cares about the overall length, not our internal chunking.
return if (dataSpec.length == C.LENGTH_UNSET.toLong()) {
C.LENGTH_UNSET.toLong()
} else {
dataSpec.length
}
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (length == 0) return 0
// Need a fresh chunk?
if (chunkRemaining == 0L) {
val spec = originalSpec ?: return C.RESULT_END_OF_INPUT
inner.close()
val nextPos = spec.position + totalRead
val remainingOverall = if (spec.length == C.LENGTH_UNSET.toLong()) {
Long.MAX_VALUE
} else {
spec.length - totalRead
}
if (remainingOverall <= 0L) return C.RESULT_END_OF_INPUT
val nextLen = remainingOverall.coerceAtMost(chunkBytes)
// Same as in open() — use buildUpon().setPosition/setLength rather
// than subrange() so the absolute position stays meaningful.
val nextSpec = spec.buildUpon()
.setPosition(nextPos)
.setLength(nextLen)
.build()
chunkRemaining = inner.open(nextSpec)
}
// Cap the read against what's left in this chunk.
val toRead = if (chunkRemaining < 0L) {
// Inner doesn't know its end either; just read what was asked.
length
} else {
length.toLong().coerceAtMost(chunkRemaining).toInt()
}
val read = inner.read(buffer, offset, toRead)
if (read != C.RESULT_END_OF_INPUT) {
totalRead += read.toLong()
if (chunkRemaining > 0L) chunkRemaining -= read.toLong()
// If chunkRemaining hits 0 here, the next read() call will roll
// to the next chunk via the block at the top.
} else if (chunkRemaining > 0L) {
// Inner ran out before its advertised end. Force chunk roll on
// next read() so we re-open at the next position.
chunkRemaining = 0L
}
return read
}
override fun close() {
try {
inner.close()
} finally {
originalSpec = null
totalRead = 0
chunkRemaining = 0
}
}
/** Factory: wrap any inner HttpDataSource.Factory. */
@UnstableApi
class Factory(
private val innerFactory: HttpDataSource.Factory,
private val chunkBytes: Long = DEFAULT_CHUNK_BYTES,
) : HttpDataSource.Factory {
override fun createDataSource(): HttpDataSource =
IosSafeHttpDataSource(innerFactory.createDataSource(), chunkBytes)
override fun setDefaultRequestProperties(
defaultRequestProperties: Map<String, String>,
): HttpDataSource.Factory {
innerFactory.setDefaultRequestProperties(defaultRequestProperties)
return this
}
}
companion object {
// YT's iOS-bound googlevideo URLs accept bounded `Range: bytes=N-M`
// requests up to roughly 900 KiB before flipping to 403. Empirically
// measured 2026-05-24 on Lucy egress: bytes=0-917503 (~896 KiB) → 206;
// bytes=0-999999 (~977 KiB) → 403. 512 KiB gives a 2× safety margin —
// small enough to survive future tightening, large enough to keep the
// open() round-trip count tolerable for a long video.
const val DEFAULT_CHUNK_BYTES: Long = 512L * 1024
}
}

View file

@ -8,7 +8,6 @@
package com.sulkta.straw.net
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW
import kotlinx.serialization.Serializable
@ -26,7 +25,7 @@ data class RydVotes(
object RydClient {
private const val TAG = "StrawRyd"
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val json = Json { ignoreUnknownKeys = true }
/** Blocking — call from Dispatchers.IO. */
fun fetch(videoId: String): RydVotes? {
@ -34,11 +33,11 @@ object RydClient {
strawLogD(TAG) { "fetch start: $videoId$url" }
val req = Request.Builder()
.url(url)
.header("User-Agent", NewPipeDownloader.USER_AGENT)
.header("User-Agent", STRAW_USER_AGENT)
.header("Accept", "application/json")
.build()
return runCatching {
NewPipeDownloader.client().newCall(req).execute().use { r ->
strawHttpClient().newCall(req).execute().use { r ->
val code = r.code
// AUD-HIGH: bounded body read to defend against OOM.
val bodyStr = r.body?.cappedString(RYD_MAX_BYTES) ?: ""

View file

@ -8,11 +8,11 @@
package com.sulkta.straw.net
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import java.security.MessageDigest
@ -35,23 +35,30 @@ data class SbSegment(
object SponsorBlockClient {
private const val TAG = "StrawSb"
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val json = Json { ignoreUnknownKeys = true }
fun fetch(
videoId: String,
categories: List<String> = listOf("sponsor"),
): List<SbSegment> {
val prefix = sha256Hex(videoId).substring(0, 4)
val urlStr = "https://sponsor.ajay.app/api/skipSegments/$prefix?" +
"categories=" + buildJsonArray(categories)
strawLogD(TAG) { "fetch: videoId=$videoId prefix=$prefix url=$urlStr" }
// HttpUrl.Builder percent-encodes query values for us. Prior
// string-concat built `?categories=["sponsor","selfpromo"]`
// with literal brackets/quotes — SB happens to accept it
// today, but the next time someone interpolates a non-enum
// string in there it becomes a URL-construction bug.
val url = "https://sponsor.ajay.app/api/skipSegments/$prefix".toHttpUrl()
.newBuilder()
.addQueryParameter("categories", buildJsonArray(categories))
.build()
strawLogD(TAG) { "fetch: videoId=$videoId prefix=$prefix" }
val req = Request.Builder()
.url(urlStr)
.header("User-Agent", NewPipeDownloader.USER_AGENT)
.url(url)
.header("User-Agent", STRAW_USER_AGENT)
.header("Accept", "application/json")
.build()
return runCatching {
NewPipeDownloader.client().newCall(req).execute().use { r ->
strawHttpClient().newCall(req).execute().use { r ->
val code = r.code
// AUD-HIGH: bounded body read.
val bodyStr = r.body?.cappedString(SB_MAX_BYTES) ?: ""

View file

@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Shared bottom-padding helper for every scrolling screen.
*
* Two things float over the bottom of the activity-level layout and
* need to be cleared:
* 1. The system navigation bar (3-button or gesture). Insets via
* WindowInsets.navigationBars.
* 2. The Straw minibar overlay. Reactive only present when
* NowPlaying.current is non-null. ~64dp tall + a small gap
* 72dp reserve.
*
* LazyColumn-based screens plumb this into `contentPadding` so items
* scroll PAST the bottom without being eaten. verticalScroll columns
* append a tail Spacer of the same height.
*/
package com.sulkta.straw.util
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.sulkta.straw.feature.player.NowPlaying
/** Combined bottom Dp: nav-bar inset + 72dp when minibar's visible. */
@Composable
fun rememberBottomBarReserveDp(): Dp {
val navBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val item by NowPlaying.current.collectAsStateWithLifecycle()
val minibar = if (item != null) 72.dp else 0.dp
return navBottom + minibar
}
/** Convenience for LazyColumn.contentPadding — adds nothing on the top/start/end. */
@Composable
fun rememberBottomContentPadding(): PaddingValues =
PaddingValues(bottom = rememberBottomBarReserveDp())

View file

@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Heuristics for the hide-shorts / hide-paid / hide-age content filters.
* Pure functions on StreamItem so any list-rendering site can call them
* with one line at row-emit time.
*
* ships only the shorts heuristic paid/age require strawcore
* flag plumbing landing previously. The empty-stub fns are here so the
* call sites we add now don't need to change when the flags arrive.
*/
package com.sulkta.straw.util
import com.sulkta.straw.feature.search.StreamItem
/**
* Best-effort short-video detector:
* - URL pattern `/shorts/<id>` reliable signal from search +
* channel pages (strawcore preserves the original URL shape).
* - Title contains `#shorts` / `#short` / "(shorts)" fallback for
* items where the URL is the canonical `watch?v=` form (RSS feed
* items always come through this way).
*/
fun looksLikeShort(item: StreamItem): Boolean {
if ("/shorts/" in item.url) return true
val t = item.title.lowercase()
return "#shorts" in t || "#short" in t || "(shorts)" in t
}
/**
* Placeholder until adds an isPaid flag via strawcore-core.
* Currently always false the hide-paid toggle still shows up in
* Settings so the user can pre-opt-in for when it lights up.
*/
fun looksLikePaid(@Suppress("UNUSED_PARAMETER") item: StreamItem): Boolean = false
/**
* Placeholder until adds an isAgeRestricted flag. Same shape
* as looksLikePaid.
*/
fun looksLikeAgeRestricted(@Suppress("UNUSED_PARAMETER") item: StreamItem): Boolean = false
/**
* Combined filter applied at row-emit. Returns the items to keep based
* on the current Settings flags. Centralized here so the policy is
* defined in one place; each calling LazyColumn just maps its source
* list through this.
*/
fun applyContentFilters(
items: List<StreamItem>,
hideShorts: Boolean,
hidePaid: Boolean = false,
hideAgeRestricted: Boolean = false,
): List<StreamItem> = items.filterNot { item ->
(hideShorts && looksLikeShort(item)) ||
(hidePaid && looksLikePaid(item)) ||
(hideAgeRestricted && looksLikeAgeRestricted(item))
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Coroutine-safe runCatching. Standard kotlin.runCatching catches
* Throwable including CancellationException, which is supposed to
* propagate so structured concurrency works.
* surfaced the bug: a runCatching around a channelInfo() call
* inside a cancelled job swallowed the cancellation, the job
* carried on past the runCatching, hit its terminal write fence
* (which only checked URL-equality, so same-URL races couldn't
* be distinguished), and clobbered the newer job's state.
*
* Always use this in coroutine bodies. The plain runCatching is
* still fine in non-suspend code (no coroutine to cancel).
*/
package com.sulkta.straw.util
import kotlinx.coroutines.CancellationException
inline fun <R> runCatchingCancellable(block: () -> R): Result<R> = try {
Result.success(block())
} catch (c: CancellationException) {
throw c
} catch (t: Throwable) {
Result.failure(t)
}

View file

@ -25,3 +25,19 @@ fun formatDuration(sec: Long): String {
val s = sec % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
}
/**
* Quick "12s ago" / "3m ago" / "5h ago" / "2d ago" for the auto-update
* "Last checked" timestamp. Future timestamps (clock skew) return the
* just-now bucket.
*/
fun formatRelativeSince(ms: Long, nowMs: Long = System.currentTimeMillis()): String {
val delta = (nowMs - ms).coerceAtLeast(0L)
val sec = delta / 1000
return when {
sec < 60 -> "${sec}s ago"
sec < 3600 -> "${sec / 60}m ago"
sec < 86_400 -> "${sec / 3600}h ago"
else -> "${sec / 86_400}d ago"
}
}

View file

@ -2,9 +2,9 @@
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Strip HTML tags from NewPipeExtractor's description.content for plain-text
* rendering. Day-3 polish replaces this with a real Markwon/Compose annotated
* renderer; for now we just want readable text.
* Strip HTML tags from video descriptions for plain-text rendering.
* Replace with a real annotated renderer (Markwon, Compose annotated
* strings) when the description UI needs richer formatting.
*/
package com.sulkta.straw.util

View file

@ -0,0 +1,136 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Capture this process's logcat into a file and return a share Intent.
* Used from the Settings "Export logs" action so users can attach a
* log dump when reporting a problem.
*
* SECURITY: The dump is filtered before being written to disk
* pre-signed googlevideo URLs, OAuth-style tokens, and anything
* matching the leak patterns below get scrubbed line-by-line. Without
* this, a user reporting a bug to Telegram would hand the chooser app
* their currently-playing session-bound streaming credentials.
*
* Android also limits logcat-via-Runtime.exec to the calling app's
* own UID on API 30+, so this captures Straw's own log lines only.
*/
package com.sulkta.straw.util
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Process
import androidx.core.content.FileProvider
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object LogDump {
/**
* Pull recent logcat, scrub sensitive substrings, write to a file
* in cacheDir, return a share-able Intent. Suspend so callers can
* stay off the main thread `proc.waitFor()` plus a multi-MB
* `copyTo` is firmly an IO operation.
*/
suspend fun capture(context: Context): Result<Intent> = withContext(Dispatchers.IO) {
runCatching {
val pid = Process.myPid()
val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
// Write to cacheDir/logs/
// narrowed the FileProvider scope from the whole cacheDir
// to just this subdir, so dumps must land here.
val logsDir = File(context.cacheDir, "logs").apply { mkdirs() }
val outFile = File(logsDir, "straw-logs-$timestamp.txt")
val tmpFile = File(logsDir, "straw-logs-$timestamp.txt.tmp")
// Sweep old dumps before writing the new one so cacheDir
// doesn't grow per export.
logsDir.listFiles { _, name ->
name.startsWith("straw-logs-") && (name.endsWith(".txt") || name.endsWith(".tmp"))
}?.forEach { it.delete() }
// -d dump-and-exit (no follow), -v threadtime is the
// most-greppable format, --pid filter restricts to our
// process so we don't exfiltrate sibling apps' chatter.
val cmd = arrayOf("logcat", "-d", "-v", "threadtime", "--pid=$pid")
val proc = ProcessBuilder(*cmd).redirectErrorStream(true).start()
tmpFile.bufferedWriter().use { out ->
proc.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
out.write(scrubLine(line))
out.newLine()
}
}
}
val exit = proc.waitFor()
if (exit != 0) {
tmpFile.delete()
throw IOException("logcat exit=$exit")
}
if (tmpFile.length() == 0L) {
tmpFile.delete()
throw IOException("logcat produced 0 bytes (sandbox restriction?)")
}
// Atomic-ish: only rename to final name on full success.
if (!tmpFile.renameTo(outFile)) {
tmpFile.delete()
throw IOException("rename failed")
}
val authority = "${context.packageName}.fileprovider"
val uri: Uri = FileProvider.getUriForFile(context, authority, outFile)
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "Straw logs $timestamp")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
}
/**
* Pre-redact known credential-shaped substrings before they hit
* disk. Cheap line-level pass adversarial-perfect would need a
* URL parser, but the regex approach catches every documented
* leak vector at zero allocation cost.
*
* Public so error-handler call sites (PlayerScreen / VideoDetail
* `playbackError`) can scrub Media3's `PlaybackException.message`
* before rendering it to the user that string includes the full
* request URI for HttpDataSource exceptions, which would otherwise
* be a leak via screenshot.
*/
fun scrubLine(line: String): String {
var s = line
// Pre-signed googlevideo URLs: keep host visible, drop path+query.
s = GOOGLEVIDEO_URL_RE.replace(s, "https://<host>.googlevideo.com/<scrubbed>")
// Long, distinctive token names — match anywhere.
s = SIGNED_PARAM_LONG_RE.replace(s, "$1=<scrubbed>")
// Short single-letter / two-letter tokens — require `[?&]`
// immediately before to avoid eating innocent counters.
s = SIGNED_PARAM_SHORT_RE.replace(s, "$1$2=<scrubbed>")
return s
}
private val GOOGLEVIDEO_URL_RE = Regex(
"""https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""",
)
// Long tokens are unique enough to match anywhere. Short tokens
// (n, mn, ms, mo, pl, ip, ei) require `[?&]` immediately before
// so we don't redact innocuous `n=42` counters from other libs.
private val SIGNED_PARAM_LONG_RE = Regex(
"""\b(signature|sparams|lsig|cpn|expire|pot|sig|key)=([^&\s"']+)""",
RegexOption.IGNORE_CASE,
)
private val SIGNED_PARAM_SHORT_RE = Regex(
"""([?&])(n|mn|ms|mo|pl|ip|ei)=([^&\s"']+)""",
RegexOption.IGNORE_CASE,
)
}

View file

@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* On-disk usage helper for the Settings Storage section. Reads the
* actual .xml file size for each SharedPreferences-backed store + the
* Coil disk-cache size, so the user can see what's eating space rather
* than guessing from cap settings.
*
* All values are best-effort: a missing file (store never written)
* returns 0; permission/IO errors return 0 and log silently. The
* displayed numbers are advisory, not authoritative.
*/
package com.sulkta.straw.util
import android.content.Context
import coil3.SingletonImageLoader
import java.io.File
object StorageUsage {
/**
* Bytes-on-disk for a SharedPreferences file. The Android framework
* writes `<dataDir>/shared_prefs/<prefsName>.xml`. dataDir is
* `context.applicationInfo.dataDir` (the parent of filesDir,
* approximately).
*/
fun sharedPrefBytes(context: Context, prefsName: String): Long {
val dataDir = context.applicationInfo.dataDir ?: return 0L
val f = File(dataDir, "shared_prefs/$prefsName.xml")
return if (f.exists()) f.length() else 0L
}
/**
* Coil's disk cache total. Returns 0 if Coil hasn't lazily
* initialized a disk cache yet (no images loaded this session).
*/
fun coilDiskCacheBytes(context: Context): Long = runCatching {
SingletonImageLoader.get(context).diskCache?.size ?: 0L
}.getOrDefault(0L)
/** Human-friendly rendering: "4.2 KB" / "13 MB" / "—" for 0. */
fun format(bytes: Long): String {
if (bytes <= 0L) return ""
val kb = bytes / 1024.0
if (kb < 1024.0) return "%.1f KB".format(kb)
val mb = kb / 1024.0
if (mb < 1024.0) return "%.1f MB".format(mb)
return "%.2f GB".format(mb / 1024.0)
}
}

View file

@ -1,24 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* NewPipeExtractor returns thumbnails as a List<Image> with width/height
* fields. Calling .firstOrNull() picks the smallest (the list is sorted
* ascending) which gave us pixelated thumbnails. This helper picks the
* largest by pixel area instead.
*/
package com.sulkta.straw.util
import org.schabi.newpipe.extractor.Image
fun bestThumbnail(images: List<Image>?): String? {
if (images.isNullOrEmpty()) return null
return images
.maxByOrNull {
val w = it.width.takeIf { v -> v > 0 } ?: 0
val h = it.height.takeIf { v -> v > 0 } ?: 0
w.toLong() * h.toLong()
}
?.url
}

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Shared YouTube-host allowlist. Originally lived inside
* SettingsImport for the import-time URL check, then two more call
* sites VideoDetailViewModel's auto channelInfo(uploaderUrl) and
* recordWatch persistence needed the same gate. Co-locating the
* set here so a future host (yewtu.be, hypothetical YT mirror) is
* one edit instead of three.
*/
package com.sulkta.straw.util
private val ALLOWED_YT_HOSTS: Set<String> = setOf(
"youtube.com", "www.youtube.com", "m.youtube.com",
"music.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com",
"youtu.be",
)
fun isAllowedYtUrl(url: String): Boolean {
val uri = runCatching { java.net.URI(url) }.getOrNull() ?: return false
// Require an http/https scheme — `//host/...` (schemeless) and
// `mailto:host` both parse with a host attribute.
val scheme = uri.scheme?.lowercase() ?: return false
if (scheme != "https" && scheme != "http") return false
// Strip a single trailing dot (RFC FQDN form) before lookup.
val host = uri.host?.lowercase()?.removeSuffix(".") ?: return false
return host in ALLOWED_YT_HOSTS
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#166534" />
</shape>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Straw adaptive icon foreground.
Canvas is 108x108dp; the visible mask-safe area is roughly the central
66x66dp box centered on (54,54). Keep the play triangle inside that.
One bold white play triangle on the deep-green ic_launcher_background.
Single strong silhouette reads cleanly at tiny launcher sizes.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M 38,30 L 38,78 L 82,54 Z" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Block both cloud auto-backup (`cloud-backup`) and direct device-to-
device transfers (`device-transfer`) for every Straw storage scope.
Watch history, search history, full subscription list, and the on-
disk feed/search caches would otherwise sync silently to the user's
Google account and ride to any restored device.
We don't WANT this content backed up — there's no account model;
there's nothing to recover. Better to ask the user to re-subscribe
than to leak their entire video-watching profile to Google Drive.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- LogDump shares logcat captures to a chooser-picked app.
Narrowed to a `logs/` subdir of cacheDir (was the entire
cacheDir) so a future bug that builds an attacker-influenced
FileProvider URI can't reach SettingsImport workdirs or
other cache state. -->
<cache-path name="logs" path="logs/" />
<!-- Completed downloads. Downloader uses
setDestinationInExternalFilesDir(DIRECTORY_MOVIES + "/audio" |
"/video"), so the FileProvider needs to be able to map those
paths back to a content:// URI when DownloadsScreen taps to
open the finished file. -->
<external-files-path name="downloads-audio" path="Movies/audio/" />
<external-files-path name="downloads-video" path="Movies/video/" />
</paths>