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.
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.
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.
a tester'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).
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
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
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.
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.