diff --git a/.forgejo/workflows/gitleaks.yml b/.forgejo/workflows/gitleaks.yml new file mode 100644 index 000000000..10d7847f3 --- /dev/null +++ b/.forgejo/workflows/gitleaks.yml @@ -0,0 +1,40 @@ +# .forgejo/workflows/gitleaks.yml +# +# Sulkta canonical gitleaks workflow. Drop a copy into every public repo at +# `.forgejo/workflows/gitleaks.yml` after the Forgejo act_runner is registered +# (task #295). +# +# Pairs with the pre-receive hook installed on every bare repo — that one is +# the strict enforcement layer (rejects the push); this one provides the +# per-PR red ✗ that branch-protection rules can require before merge. +# +# Layer 1 (this workflow): visible per-PR status, can be a required check. +# Layer 2 (pre-receive hook): strict enforcement at the server. +# Layer 3 (johnny5 cron sweep): nightly full-history sweep across all repos. + +name: gitleaks + +on: + push: + pull_request: + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Full history — gitleaks needs depth to scan a commit range. + fetch-depth: 0 + + - name: install gitleaks + run: | + curl -sSL -o gl.tar.gz \ + https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz + tar xzf gl.tar.gz gitleaks + chmod +x gitleaks + ./gitleaks version + + - name: scan + run: | + ./gitleaks detect --source . --no-banner --redact --verbose diff --git a/README.md b/README.md index aa4332165..6afc33878 100644 --- a/README.md +++ b/README.md @@ -1,153 +1,77 @@ -

We are rewriting large chunks of the codebase, to bring about a modern and stable NewPipe! You can download nightly builds here.

-

Please work on the refactor branch if you want to contribute new features. The current codebase is in maintenance mode and will only receive bugfixes.

+# Straw -

-

NewPipe

-

A libre lightweight streaming front-end for Android.

+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. -

Get it on F-Droid

+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. -

- - - - - - -

+## Install -

- - -

+F-Droid repo: -
-

ScreenshotsSupported ServicesDescriptionFeaturesInstallation and updatesContributionDonateLicense

-

WebsiteBlogFAQPress

-
+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] -> 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. -> -> PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS. +## 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) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/00.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/01.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/02.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/03.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/04.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/05.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/06.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/07.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/08.png) -

-[](fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png) -[](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 - -* 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 - - - - -## 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 - -Translation status - +``` +./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: - - - - - - -
LiberapayVisit NewPipe at liberapay.comDonate via Liberapay
+``` +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 . 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. diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 8577a49eb..5a4d5debc 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 7ee7f3ec7..880e1f4b7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -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 diff --git a/rust/README.md b/rust/README.md index 2aa515b4c..720f81fa9 100644 --- a/rust/README.md +++ b/rust/README.md @@ -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 ... -o jniLibs/ build --release diff --git a/rust/strawcore/Cargo.toml b/rust/strawcore/Cargo.toml index f693973ee..6ccd53fd5 100644 --- a/rust/strawcore/Cargo.toml +++ b/rust/strawcore/Cargo.toml @@ -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"] } diff --git a/rust/strawcore/src/channel.rs b/rust/strawcore/src/channel.rs index 67d48b8a9..ec99e1416 100644 --- a/rust/strawcore/src/channel.rs +++ b/rust/strawcore/src/channel.rs @@ -23,7 +23,8 @@ pub struct ChannelInfo { #[uniffi::export(async_runtime = "tokio")] pub async fn channel_info(input: String) -> Result { - 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 diff --git a/rust/strawcore/src/error.rs b/rust/strawcore/src/error.rs index 34069bb81..2703813b5 100644 --- a/rust/strawcore/src/error.rs +++ b/rust/strawcore/src/error.rs @@ -31,13 +31,47 @@ pub enum StrawcoreError { RequiresLogin { detail: String }, } +/// Drop the `continue=` 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 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)) => { diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs new file mode 100644 index 000000000..c50b2942c --- /dev/null +++ b/rust/strawcore/src/feed.rs @@ -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 { + 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 = 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, 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, +) -> Result, 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> = 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> { + 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 { + use futures::StreamExt; + let mut total = 0usize; + let mut buf: Vec = 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 { + let trimmed = input.trim(); + let trimmed_lower = trimmed.to_lowercase(); + // Match the ":///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 { + 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> { + 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 = 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 = None; + let mut published = String::new(); + + // What text-collecting state we're in. Replaced per element open. + let mut text_target: Option = 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()); + // 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 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 { + 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, + } +} diff --git a/rust/strawcore/src/lib.rs b/rust/strawcore/src/lib.rs index 1ca13d06c..c8353502a 100644 --- a/rust/strawcore/src/lib.rs +++ b/rust/strawcore/src/lib.rs @@ -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, diff --git a/rust/strawcore/src/runtime.rs b/rust/strawcore/src/runtime.rs index 6e1b3fbc0..336d6b4bb 100644 --- a/rust/strawcore/src/runtime.rs +++ b/rust/strawcore/src/runtime.rs @@ -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; + } + } } diff --git a/rust/strawcore/src/search.rs b/rust/strawcore/src/search.rs index b4f96395e..f99c29da9 100644 --- a/rust/strawcore/src/search.rs +++ b/rust/strawcore/src/search.rs @@ -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, 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) }) diff --git a/rust/strawcore/src/stream.rs b/rust/strawcore/src/stream.rs index 7a4ce6839..f28080f9c 100644 --- a/rust/strawcore/src/stream.rs +++ b/rust/strawcore/src/stream.rs @@ -57,7 +57,8 @@ pub struct AudioStreamItem { #[uniffi::export(async_runtime = "tokio")] pub async fn stream_info(input: String) -> Result { - 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)) diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index 13ef39dde..13fbac20b 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -39,13 +39,30 @@ configure { } 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 +// `/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) } diff --git a/strawApp/proguard-rules.pro b/strawApp/proguard-rules.pro new file mode 100644 index 000000000..fad37e118 --- /dev/null +++ b/strawApp/proguard-rules.pro @@ -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 { *; } diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml index 46abc05b7..56adc1f94 100644 --- a/strawApp/src/main/AndroidManifest.xml +++ b/strawApp/src/main/AndroidManifest.xml @@ -11,12 +11,20 @@ + + + - + @@ -39,6 +52,9 @@ + + + @@ -47,11 +63,11 @@ - + + + + + + diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt index 02396749c..1b57d3471 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt @@ -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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 670db54e3..4f9e1616f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -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(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) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 6d8a1defc..585fefc00 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -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) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 31e1f2452..d62c68852 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -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(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(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)) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt new file mode 100644 index 000000000..85fa21b09 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt @@ -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) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt new file mode 100644 index 000000000..597688f1e --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt @@ -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> = _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())).apply() + } + + private fun load(): Map = runCatching { + val s = sp.getString(KEY, null) ?: return emptyMap() + val loaded = json.decodeFromString>(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)") +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt new file mode 100644 index 000000000..eab61b975 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt @@ -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, +) + +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 = runCatching { + val s = sp.getString(KEY, null) ?: return emptyMap() + val raw = json.decodeFromString>(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) { + 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 + } + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt index 8ed488e48..1eb1bd2f8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -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> = _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 + * oldest→newest. 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): 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(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(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): 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(current.size + queries.size) + current.forEach { seen.add(it.lowercase()) } + val fresh = ArrayList(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())).apply() } fun clearSearches() { - _searches.value = emptyList() - sp.edit().remove(KEY_SEARCHES).apply() + _searches.updateAndGet { emptyList() } + sp.edit().putString(KEY_SEARCHES, json.encodeToString(emptyList())).apply() } private fun loadWatches(): List = runCatching { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt new file mode 100644 index 000000000..26ba0e16d --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt @@ -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 = 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> = _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): Playlist { + val stampNow = System.currentTimeMillis() + // Dedup within the import + stamp addedAt once. + val seen = HashSet() + val deduped = ArrayList(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) { + sp.edit().putString(KEY, json.encodeToString(list)).apply() + } + + private fun load(): List = runCatching { + val s = sp.getString(KEY, null) ?: return emptyList() + json.decodeFromString>(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)") +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt new file mode 100644 index 000000000..ffa02cd5f --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt @@ -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> = _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())).apply() + } + } + } + + private fun load(): Map = runCatching { + val s = sp.getString(KEY_POSITIONS, null) ?: return emptyMap() + json.decodeFromString>(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)") +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt new file mode 100644 index 000000000..4b9473fe2 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt @@ -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, +) + +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> = _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): List { + 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 = 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) { + 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 = runCatching { + val s = sp.getString(KEY, null) ?: return emptyList() + json.decodeFromString>(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 + } + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 484cfdfe8..7ded93aba 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -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.asStateFlow() + private val _themeMode = MutableStateFlow(loadThemeMode()) + val themeMode: StateFlow = _themeMode.asStateFlow() + + private val _cacheEnabled = MutableStateFlow(sp.getBoolean(KEY_CACHE_ENABLED, true)) + val cacheEnabled: StateFlow = _cacheEnabled.asStateFlow() + + private val _autoplayMode = MutableStateFlow(loadAutoplayMode()) + val autoplayMode: StateFlow = _autoplayMode.asStateFlow() + + private val _autoplaySkipWatched = MutableStateFlow( + sp.getBoolean(KEY_AUTOPLAY_SKIP_WATCHED, true), + ) + val autoplaySkipWatched: StateFlow = _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 = _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 = _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 = _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 = _autoUpdateCheck.asStateFlow() + + private val _autoUpdateInterval = MutableStateFlow(loadAutoUpdateInterval()) + val autoUpdateInterval: StateFlow = _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 = _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 = _latestKnownVc.asStateFlow() + + private val _latestKnownVname = MutableStateFlow( + sp.getString(KEY_LATEST_KNOWN_VNAME, "") ?: "", + ) + val latestKnownVname: StateFlow = _latestKnownVname.asStateFlow() + + /** + * Hide YouTube Shorts everywhere. Detection is multi-signal because + * each surface gives different hints: + * - Search + ChannelScreen results: URL pattern `/shorts/` 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 = _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 = _historyWatchesCap.asStateFlow() + + private val _historySearchesCap = MutableStateFlow( + loadCap(KEY_CACHE_HISTORY_SEARCHES, default = 20), + ) + val historySearchesCap: StateFlow = _historySearchesCap.asStateFlow() + + private val _resumePositionsCap = MutableStateFlow( + loadCap(KEY_CACHE_RESUME_POSITIONS, default = 500), + ) + val resumePositionsCap: StateFlow = _resumePositionsCap.asStateFlow() + + private val _searchCacheCap = MutableStateFlow( + loadCap(KEY_CACHE_SEARCH, default = 30), + ) + val searchCacheCap: StateFlow = _searchCacheCap.asStateFlow() + + private val _cacheTtl = MutableStateFlow(loadCacheTtl()) + val cacheTtl: StateFlow = _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 = _bgFeedRefreshEnabled.asStateFlow() + + private val _bgFeedRefreshInterval = MutableStateFlow(loadBgFeedInterval()) + val bgFeedRefreshInterval: StateFlow = + _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 { 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 { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt index 3a9fc8162..f25a6bbc0 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt @@ -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> = _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²) + 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): 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) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt b/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt deleted file mode 100644 index bc185b5e0..000000000 --- a/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt +++ /dev/null @@ -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 - } -} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index 9d3e57143..c2ea93b5d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -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(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, + ) + } } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index e3cb6b2e4..5415f2c95 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -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 = 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 = _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 = if (videosTab != null) { - withContext(Dispatchers.IO) { - runCatching { - ChannelTabInfo.getInfo(service, videosTab) - .relatedItems - .filterIsInstance() - .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, + ) + } } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt new file mode 100644 index 000000000..a4d7e4fc4 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt @@ -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-.zip`, Tubular as `TubularData-.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, +) { + 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 = + 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() + // 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, + ): Pair { + 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() + 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>() + // 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() + 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() + 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() + 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() +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt new file mode 100644 index 000000000..d3daaac3e --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt @@ -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 = emptyList(), +): ResolvedPlayback { + val maxRes = Settings.get().maxResolution.value.ceiling + fun pickVideo(streams: List): 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, + ) +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 1720dd4da..6c726aff2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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(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 ───────────────────────────────── 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(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( } } } - diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 06ee83b6c..d6038f9b9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -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 = 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 = emptyList(), + val related: List = 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 = 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 = 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 = _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() - ?.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 = - 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() + // 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, + ) + 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,...