Commit graph

45 commits

Author SHA1 Message Date
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
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
5b36de8888 v0.1.0-AA (vc=15): inline player on VideoDetail + fullscreen pill
Tap the 16:9 thumbnail box on VideoDetail and the video plays right
there in the card — like YouTube. Uses its own ExoPlayer (released on
nav-back via DisposableEffect) with PlayerView's built-in controls
(play/pause/seek/duration bar).

Top-right ⛶ pill on the inline player jumps to the existing fullscreen
PlayerScreen which still has the full toolset (speed picker, audio-only,
share, PiP, background, SponsorBlock chip). Restarts from 0 on entry —
seek-position handoff between inline + fullscreen is a future refinement.

Inline player state (playing/not-playing) is keyed on streamUrl so
navigating to a different video resets it back to the thumbnail-with-
play-overlay default.
2026-05-24 11:17:36 -07:00
75329867e9 v0.1.0-Z (vc=14): VideoDetail to YT-standard order
Under the thumbnail: title → uploader → chips → buttons → description →
Recommended → More from <uploader>. The Z2/W2 inverted layout shipped in
Y put discovery cards before the title — bad UX, fixed back to standard.

Inline player coming next.
2026-05-24 11:12:20 -07:00
94ef84f1ac v0.1.0-Y (vc=13): VideoDetail reorder + home search pill + status-bar padding
VideoDetail card order (top → bottom under the player thumbnail):
  Recommended → More from <uploader> → Video details
NewPipeExtractor fan-out: streamInfo brings 'related', and a parallel
ChannelInfo+VIDEOS-tab fetch brings 'moreFromChannel' (filtered to drop
the current video). Same data shape as feed/search rows; reuses RelatedRow.

Home top bar gets a YouTube-style search pill in the title slot — tap
takes you to the search screen. The drawer 'Search' entry is gone (the
pill replaces it).

Section header below the top bar — 'Latest from your subs' / 'History' —
makes which view you're on obvious. Empty subs state is friendlier.

Status-bar padding (statusBarsPadding) added to VideoDetailScreen,
SearchScreen, ChannelScreen, SettingsScreen — fixes content rendering
under the Pixel camera cutout in edge-to-edge mode.
2026-05-24 11:02:39 -07:00
9ad3302f52 v0.1.0-X (vc=12): revert to NewPipeExtractor for working playback
Phase U (rustypipe Rust extractor) rolled back. Symptom: black screen
on play, root cause: rustypipe 0.11.4's JS deobfuscator can't parse
current YouTube player.js (YT changed the obfuscation pattern, no
upstream rustypipe release since June 2025). Switching clients
(Web → TV → Android/Ios) didn't help — the deobfuscator init fires
universally.

Kept in place for the future:
- rust/strawcore/ Cargo workspace + UniFFI scaffolding
- crafting-table runtime install (rustup + 4 Android targets +
  cargo-ndk + NDK r27c)
- The U-2..U-5 commits in history (re-runnable when rustypipe is
  fixed or we fork it).

Restored from commit 9550b207a (v0.1.0-T):
- NewPipe.init() in StrawApp.onCreate
- libs.newpipe.extractor + libs.squareup.okhttp deps
- NewPipeDownloader.kt + Thumbnails.kt
- ViewModels (Search/VideoDetail/Player/Channel/SubscriptionFeed) on
  NewPipeExtractor calls
- VideoDetailScreen Download dialog using NewPipe's StreamInfo

Future-direction memo: openclaw-workspace/memory/project_rustypipe_fork.md
— fork plan + revival path for the Rust extractor when we're ready
to maintain it.

Verified working in the Android emulator: dQw4w9WgXcQ plays, ExoPlayer
reports state=PLAYING(3), position advancing, video surface rendering.
2026-05-24 09:54:59 -07:00
5be7d4c276 v0.1.0-W2 (vc=11): fix playback — TV+Ios YT clients + visible play errors
Black-screen-on-play bug: rustypipe's default player() uses YT's Web
client, which serves stream URLs that are session/UA-locked. ExoPlayer
fetching with a different UA gets a silent 403 from googlevideo and
renders a black surface.

Fix: pin stream_info() to player_from_clients(id, [Tv, Ios]) — the
TVHTML5 + iOS Innertube clients serve direct-play URLs that work in
any HTTP player. Same trick NewPipe uses. No Apple/iOS code involved
— it's just the API client identifier rustypipe sends to YT.

Also added a Player.Listener in PlayerScreen that Toasts any
PlaybackException (codeName + message) so future stream failures don't
look like silent black screens. Logs to logcat 'StrawPlayer' too.
2026-05-24 09:33:34 -07:00
a13896f5e9 v0.1.0-W (vc=10): U-4 + U-5 — channels via rustypipe + rip NewPipeExtractor
channel_info(url) UniFFI suspend fn. ChannelViewModel +
SubscriptionFeedViewModel both swap. NewPipeExtractor (Java) is OUT —
zero org.schabi.newpipe classes in the APK now.

Cleanup:
- NewPipeDownloader.kt deleted (was the OkHttp adapter)
- Thumbnails.kt deleted (rustypipe returns full URLs)
- NewPipe.init() dropped from StrawApp.onCreate
- libs.newpipe.extractor removed from build.gradle.kts
- STRAW_USER_AGENT + strawHttpClient() now live in net/Http.kt
- RydClient + SponsorBlockClient + PlayerScreen + PlaybackService all
  read from net/Http.kt instead of the extractor package

rustypipe API quirks beat:
- channel_videos(id) is the right method (channel() doesn't exist)
- ChannelInfo struct = basic metadata; Channel<T> wrapper carries
  name/avatar/banner + .content is the paginator of videos
- description is String (not Option), subscriber_count is Option<u64>

End state: strawApp Kotlin is ~UI + thin glue to strawcore. The Rust
core handles search / streamInfo / channel / channel_videos via UniFFI
suspend fns. Tokio + reqwest + rustls + rquickjs all packed in
libstrawcore.so (~6MB per ABI). APK 40MB total.
2026-05-24 09:11:14 -07:00
7327de2843 v0.1.0-V (vc=9): U-3 — streamInfo via rustypipe drives VideoDetail+Player
stream_info(url) UniFFI suspend fn replaces NewPipeExtractor's
StreamInfo.getInfo() for both VideoDetailViewModel and PlayerViewModel.
One Rust round-trip drives the detail screen render AND the player's
resolve(). The VideoDetailUiState.info field cached on detail load is
reused by the Download dialog so we don't refetch.

Deferred to U-3.5:
- like_count (rustypipe's player() doesn't surface engagement data;
  a separate query is needed)
- related (player() doesn't include 'up next'; comes from a separate
  endpoint). Kotlin gets empty list for now — RelatedRow handles it.

Type quirks vs my initial guesses (caught by cargo check):
- details.duration is u32, not Option<u32>
- channel is split into channel_id + channel_name, not a struct
- like_count doesn't exist at this query depth
- VideoFormat::Webm (lowercase mb), VideoCodec::Avc1 (not H264)
- video_only is a separate vec (video_only_streams), not a bool flag
2026-05-24 08:52:43 -07:00
7ff5ac79e5 v0.1.0-U (vc=8): Phase U-1 + U-2 — Rust core + rustypipe search
NewPipeExtractor (Java) → strawcore (Rust) migration begins. Phase U:
- U-1: Rust toolchain + UniFFI smoke test
- U-2: rustypipe search via uniffi suspend fun, SearchViewModel swapped

What landed:
- rust/strawcore — UniFFI-exported Rust crate using proc-macros.
  Builds for arm64-v8a + armeabi-v7a + x86 + x86_64 via cargo-ndk.
  Tokio multi-thread runtime singleton drives rustypipe's async API.
- strawApp/build.gradle.kts — cargoBuildHost + cargoBuild + uniffiBindgen
  Gradle Exec tasks chained into the Android build. Generated Kotlin
  bindings land in src/main/java/uniffi/strawcore/ (gitignored).
- SearchViewModel.kt — calls uniffi.strawcore.search(query) directly.
  NewPipeExtractor still in deps for VideoDetail/Player/Channel paths;
  those move to Rust in U-3 / U-4.
- Build chain quirks beat:
  * cargo absolute path in Exec tasks (PATH wasn't propagating)
  * uniffi-bindgen needs UNSTRIPPED host .so — separate cargoBuildHost
    builds a debug-profile host lib to read metadata from
  * rustypipe rustls-tls-webpki-roots avoids the openssl-sys
    cross-compile tarpit
  * rquickjs-sys 'bindgen' feature opted in (no prebuilt Android
    bindings ship; crafting-table has libclang 14)
- crafting-table runtime install (until Dockerfile catches up):
  rustup + 4 Android targets + cargo-ndk + NDK r27c. Persists in
  /caches/cargo + /caches/android-sdk via the volume mount.

APK size: 22MB (U-1) → 37MB (U-2). libstrawcore.so 3-5MB per ABI carries
rustypipe + reqwest + tokio + rustls + rquickjs. NewPipeExtractor still
in for now (still drives detail + player + channel + feed), so the
Java half is doubled up. U-5 removes it.
2026-05-24 08:36:50 -07:00
9550b207ab v0.1.0-T (vc=7): bug fixes + Opus audit pass #2 + home redesign
User-visible:
- BUG: 'clicking a second item went back to the first video' — VideoDetailViewModel
  guard was short-circuiting on Activity-scoped ViewModel reuse. Now tracks
  loadedUrl and only skips when the requested URL matches.
- BUG: PiP / window mode now auto-enters on Home gesture (Android 12+ via
  setAutoEnterEnabled). Manual PiP button reports failure cause via Toast.
- HOME REDESIGN: replaced 3-tab bottom nav with hamburger ModalNavigationDrawer.
  Default view = sub feed. Drawer items = Subscriptions / History / Search /
  Settings. Top-left hamburger like normal Android apps.

Audit pass #2 (Opus max-effort) — CRIT + HIGH fixes shipped:
- CRIT-1: PlaybackService now calls startForeground() inside onStartCommand
  with a media-playback notification + channel. Pre-fix could throw
  ForegroundServiceDidNotStartInTimeException on Android 12+ and crash-kill.
- CRIT-2: AndroidManifest service exported=false. Previously any installed
  app could craft an Intent and drive playback from attacker URLs.
- HIGH-1: 🎧 background handoff stops the activity player before starting
  the service so we don't dual-host two ExoPlayers + MediaSessions.
- HIGH-2: onStartCommand returns START_NOT_STICKY and tears down on null
  intent; no more crash-restart-crash loop after OS kills.
- HIGH-3: stop service on STATE_ENDED / STATE_IDLE via Player.Listener.
  onTaskRemoved checks playbackState properly so we don't hold WAKE_LOCK
  forever after a video ends in background.
- HIGH-4: Downloader validates scheme=https + googlevideo/youtube host
  before handing the URL to DownloadManager.
- HIGH-5: filename sanitization extended to ASCII control chars, DEL,
  Unicode bidi-override block, leading-dot, trailing whitespace.
- HIGH-6: SubscriptionFeedViewModel cancels prior in-flight refresh,
  caps parallelism at 8 via Semaphore, applies 15s per-channel timeout.
- HIGH-7: sub feed error banner now shows above cached items when refresh
  fails (previously hidden, looked indistinguishable from success).
- HIGH-8: PlayerViewModel falls back to lowest-available stream when no
  stream is under the max-resolution ceiling (was: silent black screen).
- HIGH-9: network_security_config explicit cleartextTrafficPermitted='false'
  on the RYD domain-config block (doesn't inherit from base-config).
- MED-1: PlaybackService.onDestroy nulls field before releasing session to
  close a race with onGetSession during teardown.
- MED-6: Downloader catches enqueue exceptions, returns -1L, caller toasts
  'download refused (bad URL)' instead of crashing.

Deferred (audit said 'can wait'): MED-2..5, MED-7..11, HIGH-10 UX consistency.
2026-05-24 07:49:35 -07:00
Kayos
081f238355 Straw phases P/Q/R/S — bottom nav, sub feed, downloads, background audio
Phase P — bottom navigation:
- StrawHome restructured as a Scaffold with Material3 NavigationBar.
- Three tabs: Home (search + last 10 watches), Library (full watch
  history with count), Subs (channel chips + aggregated feed).

Phase Q — subscription feed:
- New SubscriptionFeedViewModel fans out per-channel ChannelInfo +
  ChannelTabs.VIDEOS fetches in parallel via async/awaitAll.
- Each channel contributes top 5; merged across all subs, capped at
  200, sorted by view count as a soft-recency proxy (extractor doesn't
  reliably surface upload timestamps).
- 10-minute cache TTL avoids hammering YT on tab re-entry.
- Subs tab renders the feed below the avatar row with a Refresh button.

Phase R — download:
- Download button on VideoDetail (next to Play / Share). Pops a tiny
  dialog: Audio (best audioStream) or Video (best videoStream/
  videoOnly fallback).
- Uses Android's DownloadManager — saves into app-private external
  files dir (Android/data/com.sulkta.straw.debug/files/Movies/<kind>/).
  Notification + progress for free. No WRITE_EXTERNAL_STORAGE needed.
- Filenames sanitized (no /:*?\"<>| chars), capped at 120 chars.

Phase S — background audio:
- New "Background" overlay button (🎧) on the player. Tap to pause the
  activity player and start PlaybackService with the audio URL.
- PlaybackService is a Media3 MediaSessionService with its own ExoPlayer
  configured with our custom DataSource.Factory (User-Agent set, cross-
  protocol redirects). Foreground service + media notification.
- Audio survives activity death — swipe the app out of recents, audio
  keeps playing. Stop via notification or open-the-app-and-tap-stop.
- onTaskRemoved keeps the service alive iff something is playing.

Versions shipped: P+Q as vc=4, R as vc=5, S as vc=6. Each landed in the
F-Droid repo for the day-by-day refresh path.

Day-N+ ideas: real MediaController unification (single Player for both
foreground + background paths), MergingMediaSource on the service side
for high-res YT videos, real upload-timestamp sort for feed once the
extractor exposes it consistently, queue/playlist.
2026-05-24 04:30:06 -07:00
Kayos
fa97b698fe Straw phase O: related videos + max-resolution picker (v0.1.0-O / vc=3)
VideoDetail screen:
- New "Related" section at the bottom — pulls
  StreamInfo.relatedItems, filters to StreamInfoItem, renders as
  inline thumbnail rows. Tap → push another VideoDetail. Up to 20
  items shown. Each row uses bestThumbnail() for hi-res.

Settings screen + PlayerViewModel:
- New "Playback" section with a Max-Resolution picker:
  Auto / 1080p / 720p / 480p / 360p / 144p. Persisted to
  SharedPreferences (KEY_MAX_RES) via SettingsStore.maxResolution
  StateFlow.
- PlayerViewModel.resolve filters videoStreams + videoOnlyStreams by
  the ceiling before picking the max-bitrate one. Auto (Int.MAX_VALUE)
  is unchanged behavior. Choosing 720p caps the renderer so 1080p/4K
  streams are skipped — saves bandwidth on mobile + helps low-end
  decoders.

Phase P next ideas: bottom navigation tabs (Home / Subs feed /
Library), Download (audio + video), the MediaSessionService refactor
for true background audio after activity death.
2026-05-24 03:44:54 -07:00
Kayos
253c5e268b Straw phase N: share + playback speed + audio-only toggle (v0.1.0-N / vc=2)
Player overlay (top-right) now hosts four buttons in a row:
- Speed: 1× by default; tap opens a dialog of 0.25× / 0.5× / 0.75× / 1× /
  1.25× / 1.5× / 1.75× / 2×. Applies via exoPlayer.playbackParameters.
- Audio-only toggle (📻/📺): toggles Player.TRACK_TYPE_VIDEO via
  TrackSelectionParameters. Saves bandwidth + battery for screen-off
  listening. Toast confirms state.
- Share (↗): Intent.ACTION_SEND with text/plain containing the YouTube
  URL + the video title as EXTRA_SUBJECT. Hands off to Android share
  sheet.
- PiP (⊟): same as M-1 but now part of the row instead of its own
  floating square.

VideoDetail screen: Play button now lives in a Row with an OutlinedButton
"Share" that fires the same ACTION_SEND chooser. Same UX surface for
users who land on detail without going to player.

Version: STRAW_VERSION_CODE 1 → 2, STRAW_VERSION_NAME "0.1.0-day1" →
"0.1.0-N" so the F-Droid client sees this as an upgrade.

Phase O next (per Cobb's "where are all the features"): quality picker,
related videos on detail, download (audio + video).
2026-05-23 21:20:15 -07:00
Kayos
7894fe5a4d Straw phase M-2: MediaSession + audio focus + service skeleton
Wraps the existing Activity-owned ExoPlayer in a Media3 MediaSession. Side
effects:
- Lock-screen media controls (play/pause)
- System media notification with play/pause buttons
- Audio focus + ducking (other audio dims when straw plays)
- Bluetooth + headset hardware-button routing
- Plays nicely alongside other media apps' notifications

The ExoPlayer is also configured with AudioAttributes (USAGE_MEDIA +
CONTENT_TYPE_MOVIE) and handleAudioFocus=true so other apps can request
focus correctly (e.g., a phone call ducks/pauses straw).

Also scaffolded but NOT yet wired:
- PlaybackService extending MediaSessionService — the foundation for
  true background-after-Activity-kill audio. Manifest entry + deps
  added (media3-session 1.4.1, concurrent-futures-ktx 1.2.0). The
  Activity-to-Service migration via MediaController is M-3 work.
- POST_NOTIFICATIONS, FOREGROUND_SERVICE,
  FOREGROUND_SERVICE_MEDIA_PLAYBACK, WAKE_LOCK permissions in manifest.

For the user right now: lock the phone while a video plays — media
controls appear on the lock screen. Open another app — notification with
play/pause sits in the shade. Press a headset's play/pause — straw
responds. Pull the home screen — PiP keeps the video floating + audio
continues. Full screen-off-background-audio after killing the activity
arrives in M-3.
2026-05-23 20:46:47 -07:00
Kayos
1578de5dbb Straw phase M-1: tap-thumbnail-to-play + hi-res thumbs + Picture-in-Picture
Cobb's real-use feedback: "playing a video is a button under a very
pixelated thumbnail, no playing in window mode, ... where are all the
features." This is the visible-UX pass.

Thumbnails:
- New util/Thumbnails.kt#bestThumbnail picks the highest-res image from
  NewPipeExtractor's List<Image> by w*h pixel area. Was firstOrNull()
  which is the smallest in the sorted list. Applied across Search,
  Channel video rows, Channel avatar + banner, VideoDetail.

Tap-to-play:
- VideoDetail thumbnail is now wrapped in a clickable Box that fires
  onPlay(). Centered semi-transparent black circle with a white play
  triangle overlay so the affordance is obvious. The standalone "Play"
  button below stays for accessibility (tap target consistency).

Picture-in-Picture:
- Manifest: android:supportsPictureInPicture="true" on StrawActivity +
  added screenLayout|smallestScreenSize|screenSize to configChanges so
  rotation into PiP doesn't recreate.
- PlayerScreen: top-right floating button enters PiP with 16:9 aspect.
- Lifecycle ON_STOP observer now checks isInPictureInPictureMode and
  skips pausing when we're in PiP (was killing playback on PiP entry).

Phase M-2 next: MediaSession + background audio + foreground service
(the "play with screen off / lock screen controls" feature).
2026-05-23 20:40:53 -07:00
Kayos
2fd439cac8 Straw audit-fix sprint: CRIT-1 + HIGH-9 + targeted MED
Phase L. Triages findings from the Opus max-effort audit
(memory/2026-05-23-night3-straw later; agent report inline).

CRIT-1: ACTION_SEND URL extracted from arbitrary text/plain share now
  re-validated via URI host check before reaching NewPipeExtractor.
  Manifest scheme also validated for VIEW. Regex broadened to accept
  music.youtube.com + youtube-nocookie. Host set expanded.

HIGH: usesCleartextTraffic="true" removed from manifest (was redundant
  with network_security_config + misleading to reviewers).

HIGH: NewPipeDownloader hardens header copy — runCatching around addHeader
  to survive poisoned response headers with \r/\n. Explicit UA added AFTER
  upstream header copy so it wins. Switched to OkHttp 5 extension form
  for toRequestBody.

HIGH: Bounded response bodies via new util `cappedString(maxBytes)`. Caps
  at 8MiB (NewPipeExtractor), 1MiB (SponsorBlock), 256KiB (RYD). Defends
  against OOM via gigabyte response. Partial defense — chunked transfers
  without Content-Length still streamed up to cap.

HIGH: HistoryStore / SettingsStore / SubscriptionsStore moved from
  non-atomic `_flow.value = next` to `_flow.updateAndGet { ... }`. Closes
  the read-modify-write race that could drop concurrent writes.

HIGH: VideoDetailViewModel.load had a dead-code `if (...)` block with no
  body — falls through every call. Replaced with a real early-return on
  detail-already-loaded.

HIGH: ChannelViewModel picked `info.tabs.firstOrNull()` which is YouTube's
  curated "Home" tab. Switched to `firstOrNull { ChannelTabs.VIDEOS in
  contentFilters }` with fallback. Channel screen now shows actual videos.

HIGH: PlayerScreen SponsorBlock skip loop hardened:
  - dedup skipped UUIDs so re-listen doesn't fight the user
  - poll 250ms → 150ms reduces sponsor leak through buffering
  - `isPlaying` → `playbackState != IDLE/ENDED` so buffering doesn't miss
  - clamp seek away from duration boundary (prevents past-end jank)
  - filter POI-style point segments (start ≈ end)

MED: ExoPlayer pause on app background via Lifecycle.Event.ON_STOP
  observer. Was silently playing audio with no MediaSession when app
  was backgrounded.

MED: Log.d/Log.w gated behind BuildConfig.DEBUG via new strawLogD/strawLogW
  inline helpers in util/Log.kt. Lambda body skipped in release too.
  Log.i remains unguarded for user-quotable events. RydClient +
  SponsorBlockClient + PlayerScreen updated.

MED: Hardcoded version "v0.1.0" in StrawHome replaced with
  BuildConfig.VERSION_NAME. Now reads "v0.1.0-day1" matching ProjectConfig.

MED: Triplicated formatCount/formatViews/formatDuration extracted to
  util/Formatting.kt. Search/Channel/VideoDetail import the shared
  functions.

MED: SponsorBlockClient.buildJsonArray uses kotlinx-serialization Json
  encoder instead of hand-rolled string concat — defends against future
  user-typed category names breaking the URL.

MED: Description regex passes capped to 20k input chars before stripHtml
  on detail screen — defends against ANR on multi-MB descriptions.

Deferred to phase M (not in this sprint):
  - R8 + ProGuard rules for release builds (isMinifyEnabled=true)
  - ExoPlayer hoisting into PlayerViewModel (AndroidViewModel)
  - DI / Koin to replace `error("not initialized")` singleton accessors
  - String resources / i18n
  - rememberSaveable nav stack for process-death restore
  - onNewIntent override for in-running YouTube URL shares
  - certificate pinning on RYD endpoint
2026-05-23 20:23:34 -07:00
Kayos
01496c647a Straw phase K: subscriptions store + Subscribe button + Home section
New SubscriptionsStore (SharedPrefs-lite, same pattern as History/Settings).
Holds Set<ChannelRef> { url, name, avatar }.

ChannelScreen header now has a "Subscribe / Subscribed" button on the
right. Button toggles membership; UI updates immediately via StateFlow.

StrawHome: new "Subscriptions" section above "Recently watched", a
LazyRow of subscribed channels (avatar + name, 80dp chips). Tap a
subscription chip → opens that channel.

StrawApp.onCreate: Subscriptions.init(this).

Day-4 ideas: auto-aggregate latest videos from subs on Home (no more
"hit search to see videos"), sub-feed-only screen, channel notifications.
2026-05-23 20:02:52 -07:00
Kayos
06e6ec64e3 Straw phase J: tappable uploader → channel browse
New Screen.Channel(channelUrl, name). ChannelViewModel calls
NewPipeExtractor's ChannelInfo.getInfo() + first ChannelTab (Videos) for
the list of streams. ChannelScreen renders banner + circular avatar +
subscriber count + LazyColumn of recent videos.

Wiring:
- SearchViewModel.StreamItem now carries uploaderUrl from
  StreamInfoItem.uploaderUrl.
- VideoDetailViewModel.VideoDetail likewise.
- VideoDetailScreen: uploader name is now a clickable Text in primary
  color when uploaderUrl is non-null. Tap → Screen.Channel.
- StrawActivity routes Screen.Channel to ChannelScreen.

Smoke test: tapping "jawed" on the Me-at-the-zoo detail screen opens the
jawed channel — banner, avatar, 6.1M subscribers, the one video he ever
uploaded (this still cracks me up).

Day-4: tappable uploader in search row, channel tabs (Playlists, Shorts),
subscription toggle wired to a Subscriptions store.
2026-05-23 19:58:37 -07:00
Kayos
6f5e1ed199 Straw phase I: SB segment count chip + clear-history buttons
Detail screen: VideoDetailViewModel now also fetches SponsorBlock segment
count for the user's currently-enabled categories alongside RYD. When >0,
detail screen shows a "⏭ N skip(s)" AssistChip next to the like/dislike
chips. Lets the user see at-a-glance whether SB is going to do anything
on this video before tapping Play.

Settings screen: "History" section at the bottom with two OutlinedButtons —
"Clear watch history" and "Clear searches". Each calls the corresponding
HistoryStore.clear* method. List updates immediately via the StateFlow.

Empty-category set on SB now means "skip the API call" (was already
checking this in PlayerViewModel.resolve, now also in VideoDetailViewModel).
2026-05-23 19:54:37 -07:00
Kayos
ce3ba9afa2 Straw phase H: Settings screen + SponsorBlock category toggles
New: SettingsStore (SharedPreferences-lite, same pattern as HistoryStore)
exposes a Set<SbCategory> StateFlow.

7 SponsorBlock categories surfaced as toggle rows in a new Settings screen:
sponsor (on by default), selfpromo, intro, outro, interaction (reminders),
music_offtopic (talking in music videos), filler.

Wiring:
- StrawApp.onCreate: Settings.init(this)
- StrawHome: new Settings gear icon next to "straw v0.1.0" header. Tap
  routes to Screen.Settings.
- PlayerViewModel.resolve: reads Settings.get().sbCategories on resolve.
  Empty set = SponsorBlock skip disabled (no API call).

Material-icons-core dep added (1.7.5) for the Icons.Filled.Settings glyph.

Day-4 ideas: theme override, default audio-only playback, preferred quality,
clear history buttons.
2026-05-23 19:51:43 -07:00
Kayos
b3a0972909 Straw phase G: recent watches + search history (SharedPreferences-lite)
New: HistoryStore — JSON-encoded SharedPreferences for two lists. Watches
keyed by videoId (max 50, FIFO), searches deduped case-insensitively (max
20). Surfaced as StateFlow so screens recompose live.

Wiring:
- StrawApp.onCreate: History.init(this)
- VideoDetailViewModel.load: recordWatch after StreamInfo resolves
- SearchViewModel.submit: recordSearch on each query

UI:
- StrawHome: redesigned. Tight inline header, full-width Search button,
  "Recently watched" LazyColumn below. Tap a recent row → VideoDetail.
- SearchScreen: when query is blank, show recent searches as
  AssistChip row. Tap a chip → onQueryChange + submit (instant search).

Day-4 graduates to Room when there's a real query pattern that SP can't
serve (date ranges, full-text search). For now the JSON-blob approach
ships in 30 minutes vs an hour of Room+KSP plumbing.
2026-05-23 19:47:29 -07:00
Kayos
f3b78b4530 Straw phase F: visible polish — RYD, HTML, intent filter, network sec
Four polish items, all visible:
1. Fixed RYD URL: was returnyoutubedislike.com (the website, 404s on /votes)
   → returnyoutubedislikeapi.com (the actual API).
2. Bundled Sectigo "Public Server Authentication CA DV R36" intermediate
   as an additional trust anchor (network_security_config.xml) for the
   .com web host — the API uses Google Trust Services already so this
   is defensive, in case we ever fetch the landing page.
3. Added intent-filter for YouTube URLs (VIEW + SEND) on StrawActivity.
   Single-task launchMode; pickYouTubeUrl() routes the initial Intent
   to Screen.VideoDetail(url) instead of Home.
4. stripHtml() utility removes <br>, </p>, and other tags from
   NewPipeExtractor's description. Chapter timestamps now render
   readably; raw HTML gone.

Also added Log.d to SponsorBlockClient and RydClient for visible
verification (StrawSb / StrawRyd tags). PlayerScreen now shows a
"SB: N segments" overlay chip in the top-left so the user can see
that SponsorBlock is armed.

Verified on Android 14 emulator with "Me at the zoo" intent:
- RYD: 👍 18.9M / 👎 424.2K renders
- Intent: direct URL → VideoDetail (no Home pass-through)
- HTML: chapter timestamps render clean
2026-05-23 19:40:08 -07:00
Kayos
496ed30bda Sulkta day-2: search → detail → player → SponsorBlock + RYD
Phase A: NewPipeExtractor + OkHttp Downloader wired in. Search bar +
LazyColumn results. Tap = navigate to detail.

Phase B: VideoDetail screen — StreamInfo metadata + Return YouTube
Dislike chips + description.

Phase C: Media3 ExoPlayer in Compose. Resolves StreamInfo to best
playable: DASH MPD → HLS → combined progressive → merged
videoOnly+audio.

Phase D: SponsorBlock SHA-256 prefix lookup. 250ms position-poll loop
inside PlayerScreen — exoPlayer.seekTo(segment.end) when entering
a sponsor segment. Toast on skip.

Phase E: Verified live on Android 14 emulator. linus tech tips search
returns real results with thumbnails; tapped result opens detail;
hit Play → video plays through ExoPlayer.

Architecture: everything in :strawApp for now (not pushed into :shared
yet — KMP refactor is day-3). Pure-state nav (sealed Screen + stack,
no nav library).

Known polish gaps (day-3): RYD chips render empty on some videos,
description has raw HTML (markdown render needed), no Koin DI yet,
no persistence.

GPL-3.0-or-later per upstream NewPipe.
2026-05-23 19:22:52 -07:00
Kayos
ff4dc6f121 Sulkta day-1: straw — KMP/Compose YouTube client fork
Initial Sulkta fork of NewPipe with a new :strawApp module that ships a
clean Compose-based Android APK at applicationId com.sulkta.straw.

What's here:
- :strawApp — thin Android application shell, MaterialTheme + StrawHome
  Composable. Lives alongside legacy :app so we don't break upstream.
- buildSrc — STRAW_APPLICATION_ID/VERSION constants alongside the existing
  NEWPIPE_APPLICATION_ID_OLD/NEW.
- docs/sulkta — RECON.md (NewPipe codebase breakdown) + DECISIONS.md
  (stack + scope decisions).

NewPipe's :shared KMP scaffold is at 892 LOC and renders nothing; this
fork picks up there and races ahead. Day-1 ships a hello APK; day-2 wires
NewPipeExtractor + Media3 player + SponsorBlock + Return YouTube Dislike.

GPL-3.0-or-later per upstream.
2026-05-23 17:37:55 -07:00