Compare commits
71 commits
rollback/v
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 42cb945654 | |||
| 5a757bea23 | |||
| d73a4b53aa | |||
| 23fb6f52b0 | |||
| 5f2ba264b0 | |||
| c960a1f424 | |||
| 796244e065 | |||
| dd151e322d | |||
| 7156208c3c | |||
| 944fbd4335 | |||
| 7bd2740055 | |||
| 6775f8252f | |||
| 6cc789a8a0 | |||
| 26c9483b94 | |||
| aead95f1bc | |||
| c4bf7446c9 | |||
| 2e75938f4e | |||
| 7fff36c5e3 | |||
| 8dec2f2621 | |||
| 50f4ce0a6c | |||
| 12acf41c08 | |||
| 3a57696b46 | |||
| 341261584a | |||
| ccd24c4ed3 | |||
| fbccdce65a | |||
| 080346716b | |||
| e26a3eca19 | |||
| ebe1fc8464 | |||
| dc1fff00db | |||
| 208cdf6326 | |||
| 714a2f8a92 | |||
| 0f946d8b4e | |||
| 62cc18c940 | |||
| 964bcddb3a | |||
| 02381edf03 | |||
| 406fd8924a | |||
| c3583457fb | |||
| c515fabf71 | |||
| 5d9cf3e370 | |||
| 2cfb26bbd3 | |||
| 10154c380b | |||
| ecc54aaf38 | |||
| da48109a4d | |||
| b8325d1726 | |||
| cbdba302ce | |||
| 780bb6152c | |||
| ec9d2f37af | |||
| 567423336c | |||
| d1ee9379e0 | |||
| 8f7ec129b3 | |||
| e76a325faa | |||
| a776fbf2e4 | |||
| c74b06436f | |||
| 69560889ae | |||
| 2afdcf3d5c | |||
| 544035b30c | |||
| 9aafc003cb | |||
| 20ee8023c1 | |||
| 29ffed265b | |||
| 2e339814fd | |||
| 35f5affec3 | |||
| 885398e3bd | |||
| 21fc81ee77 | |||
| 1443bb8ef7 | |||
| 3ff9740c40 | |||
| 1be4c4265f | |||
| e7d45aa6b4 | |||
| 599d299b2a | |||
| 709af57f42 | |||
| f70b8b71b9 | |||
| e80fa4252c |
186
README.md
|
|
@ -1,153 +1,77 @@
|
|||
<h3 align="center">We are <i>rewriting</i> large chunks of the codebase, to bring about <a href="https://newpipe.net/blog/pinned/announcement/newpipe-0.27.6-rewrite-team-states/#the-refactor">a modern and stable NewPipe</a>! You can download nightly builds <a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases">here</a>.</h3>
|
||||
<h4 align="center">Please work on the <code>refactor</code> branch if you want to contribute <i>new features</i>. The current codebase is in maintenance mode and will only receive <i>bugfixes</i>.</h4>
|
||||
# Straw
|
||||
|
||||
<p align="center"><a href="https://newpipe.net"><img src="assets/new_pipe_icon_5.png" width="150"></a></p>
|
||||
<h2 align="center"><b>NewPipe</b></h2>
|
||||
<h4 align="center">A libre lightweight streaming front-end for Android.</h4>
|
||||
A Sulkta fork of [NewPipe](https://github.com/TeamNewPipe/NewPipe). Android YouTube
|
||||
client, Compose UI, Media3 player, with [SponsorBlock](https://sponsor.ajay.app/)
|
||||
and [Return YouTube Dislike](https://returnyoutubedislike.com/) baked in.
|
||||
|
||||
<p align="center"><a href="https://f-droid.org/packages/org.schabi.newpipe/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on-en.svg" alt="Get it on F-Droid" width=206/></a></p>
|
||||
The extractor is `strawcore`, a Rust port of NewPipeExtractor exposed to Kotlin
|
||||
via UniFFI. No InnerTube/JS deobf code path lives on the JVM anymore.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/releases" alt="GitHub NewPipe releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe.svg" ></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe-nightly/releases" alt="GitHub NewPipe nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-nightly.svg?labelColor=purple&label=dev%20nightly"></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases" alt="GitHub NewPipe refactor nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-refactor-nightly.svg?labelColor=purple&label=refactor%20nightly"></a>
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0" alt="License: GPLv3"><img src="https://img.shields.io/badge/License-GPL%20v3-blue.svg"></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/actions/workflows/ci.yml/badge.svg?branch=dev&event=push"></a>
|
||||
<a href="https://hosted.weblate.org/engage/newpipe/" alt="Translation Status"><img src="https://hosted.weblate.org/widgets/newpipe/-/svg-badge.svg"></a>
|
||||
</p>
|
||||
## Install
|
||||
|
||||
<p align="center">
|
||||
<a href="https://web.libera.chat/#newpipe" alt="IRC channel: #newpipe"><img src="https://img.shields.io/badge/IRC%20chat-%23newpipe-brightgreen.svg"></a>
|
||||
<a href="https://matrix.to/#/#newpipe:matrix.newpipe-ev.de" alt="Matrix channel: #newpipe"><img src="https://img.shields.io/badge/Matrix%20chat-%23newpipe-blue"></a>
|
||||
</p>
|
||||
F-Droid repo: <https://fdroid.sulkta.com/fdroid/repo>
|
||||
|
||||
<hr>
|
||||
<p align="center"><a href="#screenshots">Screenshots</a> • <a href="#supported-services">Supported Services</a> • <a href="#description">Description</a> • <a href="#features">Features</a> • <a href="#installation-and-updates">Installation and updates</a> • <a href="#contribution">Contribution</a> • <a href="#donate">Donate</a> • <a href="#license">License</a></p>
|
||||
<p align="center"><a href="https://newpipe.net">Website</a> • <a href="https://newpipe.net/blog/">Blog</a> • <a href="https://newpipe.net/FAQ/">FAQ</a> • <a href="https://newpipe.net/press/">Press</a></p>
|
||||
<hr>
|
||||
Add the repo in your F-Droid client of choice, then install Straw.
|
||||
|
||||
*Read this document in other languages: [Deutsch](doc/README.de.md), [English](README.md), [Español](doc/README.es.md), [Français](doc/README.fr.md), [हिन्दी](doc/README.hi.md), [Italiano](doc/README.it.md), [한국어](doc/README.ko.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [ਪੰਜਾਬੀ ](doc/README.pa.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Soomaali](doc/README.so.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md), [অসমীয়া](doc/README.asm.md), [Српски](doc/README.sr.md), [العربية](README.ar.md)*
|
||||
The app also self-updates from the same repo when an APK lands there with a
|
||||
higher `versionCode`.
|
||||
|
||||
> [!warning]
|
||||
> <b>THIS APP IS IN BETA, SO YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE IN OUR GITHUB REPOSITORY BY FILLING OUT THE ISSUE TEMPLATE.</b>
|
||||
>
|
||||
> <b>PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS.</b>
|
||||
## What's in
|
||||
|
||||
## Screenshots
|
||||
- Search, video detail, channel pages, playlists
|
||||
- Inline player + fullscreen + minibar + background audio + PiP
|
||||
- Media3 ExoPlayer (DASH / HLS / progressive / merged DASH chunks)
|
||||
- SponsorBlock auto-skip (categories user-toggleable)
|
||||
- Return YouTube Dislike on video detail
|
||||
- RSS-based subscription feed (fast — ~1s for 50 subs)
|
||||
- Hide-shorts / hide-paid / hide-age-restricted feed filters
|
||||
- Resume positions + watch history + search history
|
||||
- Local playlists, downloads (video + audio)
|
||||
- NewPipe-format settings import (subs + playlists + history)
|
||||
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/00.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/00.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/01.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/02.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/03.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/04.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/05.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/06.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/07.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/08.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/08.png)
|
||||
<br/><br/>
|
||||
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png" width=405>](fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png" width=405>](fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png)
|
||||
## What's out (on purpose)
|
||||
|
||||
### Supported Services
|
||||
- Trending / algorithmic feeds. Subscriptions only.
|
||||
- iOS / desktop targets. Android-only for now.
|
||||
- Google Play Services anything.
|
||||
|
||||
NewPipe currently supports these services:
|
||||
## Layout
|
||||
|
||||
<!-- We link to the service websites separately to avoid people accidentally opening a website they didn't want to. -->
|
||||
* YouTube ([website](https://www.youtube.com/)) and YouTube Music ([website](https://music.youtube.com/)) ([wiki](https://en.wikipedia.org/wiki/YouTube))
|
||||
* PeerTube ([website](https://joinpeertube.org/)) and all its instances (open the website to know what that means!) ([wiki](https://en.wikipedia.org/wiki/PeerTube))
|
||||
* Bandcamp ([website](https://bandcamp.com/)) ([wiki](https://en.wikipedia.org/wiki/Bandcamp))
|
||||
* SoundCloud ([website](https://soundcloud.com/)) ([wiki](https://en.wikipedia.org/wiki/SoundCloud))
|
||||
* media.ccc.de ([website](https://media.ccc.de/)) ([wiki](https://en.wikipedia.org/wiki/Chaos_Computer_Club))
|
||||
|
||||
As you can see, NewPipe supports multiple video and audio services. Though it started off with YouTube, other people have added more services over the years, making NewPipe more and more versatile!
|
||||
|
||||
Partially due to circumstance, and partially due to its popularity, YouTube is the best supported out of these services. If you use or are familiar with any of these other services, please help us improve support for them! We're looking for maintainers for SoundCloud and PeerTube.
|
||||
|
||||
If you intend to add a new service, please get in touch with us first! Our [docs](https://teamnewpipe.github.io/documentation/) provide more information on how a new service can be added to the app and to the [NewPipe Extractor](https://github.com/TeamNewPipe/NewPipeExtractor).
|
||||
|
||||
## Description
|
||||
|
||||
NewPipe works by fetching the required data from the official API (e.g. PeerTube) of the service you're using. If the official API is restricted (e.g. YouTube) for our purposes, or is proprietary, the app parses the website or uses an internal API instead. This means that you don't need an account on any service to use NewPipe.
|
||||
|
||||
Also, since they are free and open source software, neither the app nor the Extractor use any proprietary libraries or frameworks, such as Google Play Services. This means you can use NewPipe on devices or custom ROMs that do not have Google apps installed.
|
||||
|
||||
### Features
|
||||
|
||||
* Watch videos at resolutions up to 4K
|
||||
* Listen to audio in the background, only loading the audio stream to save data
|
||||
* Popup mode (floating player, aka Picture-in-Picture)
|
||||
* Watch live streams
|
||||
* Show/hide subtitles/closed captions
|
||||
* Search videos and audios (on YouTube, you can specify the content language as well)
|
||||
* Enqueue videos (and optionally save them as local playlists)
|
||||
* Show/hide general information about videos (such as description and tags)
|
||||
* Show/hide next/related videos
|
||||
* Show/hide comments
|
||||
* Search videos, audios, channels, playlists and albums
|
||||
* Browse videos and audios within a channel
|
||||
* Subscribe to channels (yes, without logging into any account!)
|
||||
* Get notifications about new videos from channels you're subscribed to
|
||||
* Create and edit channel groups (for easier browsing and management)
|
||||
* Browse video feeds generated from your channel groups
|
||||
* View and search your watch history
|
||||
* Search and watch playlists (these are remote playlists, which means they're fetched from the service you're browsing)
|
||||
* Create and edit local playlists (these are created and saved within the app, and have nothing to do with any service)
|
||||
* Download videos/audios/subtitles (closed captions)
|
||||
* Open in Kodi
|
||||
* Watch/Block age-restricted material
|
||||
|
||||
<!-- Hidden span to keep old links compatible. You should remove this span if you're translating the README into another language.-->
|
||||
<span id="updates"></span>
|
||||
|
||||
## Installation and updates
|
||||
You can install NewPipe using one of the following methods:
|
||||
1. Add our custom repo to F-Droid and install it from there. The instructions are here: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/
|
||||
2. Download the APK from [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases), [compare the signing key](#apk-info) and install it.
|
||||
3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, and then push the update to users.
|
||||
4. Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods.
|
||||
5. If you're interested in a specific feature or bugfix provided in a Pull Request in this repo, you can also download its APK from within the PR. Read the PR description for instructions. The great thing about PR-specific APKs is that they're installed side-by-side the official app, so you don't have to worry about losing your data or messing anything up.
|
||||
|
||||
We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other (meaning that if you installed NewPipe using either method 1 or 2, you can also update NewPipe using the other), but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app. When using method 5, each APK is signed with a different random key supplied by GitHub Actions, so you cannot even update it. You will have to backup and restore the app data each time you wish to use a new APK.
|
||||
|
||||
In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's core functionality breaks and F-Droid doesn't have the latest update yet), we recommend following this procedure:
|
||||
1. Back up your data via Settings > Backup and Restore > Export Database so you keep your history, subscriptions, and playlists
|
||||
2. Uninstall NewPipe
|
||||
3. Download the APK from the new source and install it
|
||||
4. Import the data from step 1 via Settings > Backup and Restore > Import Database
|
||||
|
||||
> [!Note]
|
||||
> When you're importing a database into the official app, always make sure that it is the one you exported _from_ the official app. If you import a database exported from an APK other than the official app, it may break things. Such an action is unsupported, and you should only do so when you're absolutely certain you know what you're doing.
|
||||
|
||||
### APK Info
|
||||
|
||||
This is the SHA fingerprint of NewPipe's signing key to verify downloaded APKs which are signed by us. The fingerprint is also available on [NewPipe's website](https://newpipe.net#download). This is relevant for method 2.
|
||||
```
|
||||
CB:84:06:9B:D6:81:16:BA:FA:E5:EE:4E:E5:B0:8A:56:7A:A6:D8:98:40:4E:7C:B1:2F:9E:75:6D:F5:CF:5C:AB
|
||||
strawApp/ Sulkta-authored app — Compose UI, Media3 wiring, SB + RYD clients
|
||||
rust/ strawcore — UniFFI wrapper around the Rust extractor
|
||||
shared/ KMP scaffold inherited from upstream NewPipe (unused for now)
|
||||
app/ Upstream NewPipe :app module — kept for reference
|
||||
```
|
||||
|
||||
## Contribution
|
||||
Whether you have ideas, translations, design changes, code cleaning, or even major code changes, help is always welcome. The app gets better and better with each contribution, no matter how big or small! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md).
|
||||
## Build
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/newpipe/">
|
||||
<img src="https://hosted.weblate.org/widgets/newpipe/-/287x66-grey.png" alt="Translation status" />
|
||||
</a>
|
||||
```
|
||||
./gradlew :strawApp:assembleDebug
|
||||
```
|
||||
|
||||
## Donate
|
||||
If you like NewPipe, you're welcome to send a donation. We prefer Liberapay, as it is both open-source and non-profit. For further info on donating to NewPipe, please visit our [website](https://newpipe.net/donate).
|
||||
Requires the Rust toolchain plus the four Android targets:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="https://liberapay.com/TeamNewPipe/"><img src="https://upload.wikimedia.org/wikipedia/commons/2/27/Liberapay_logo_v2_white-on-yellow.svg" alt="Liberapay" width="80px" ></a></td>
|
||||
<td><a href="https://liberapay.com/TeamNewPipe/"><img src="assets/liberapay_qr_code.png" alt="Visit NewPipe at liberapay.com" width="100px"></a></td>
|
||||
<td><a href="https://liberapay.com/TeamNewPipe/donate"><img src="assets/liberapay_donate_button.svg" alt="Donate via Liberapay" height="35px"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
rustup target add aarch64-linux-android armv7-linux-androideabi \
|
||||
x86_64-linux-android i686-linux-android
|
||||
cargo install cargo-ndk uniffi-bindgen
|
||||
```
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
The NewPipe project aims to provide a private, anonymous experience for using web-based media services. Therefore, the app does not collect any data without your consent. NewPipe's privacy policy explains in detail what data is sent and stored when you send a crash report, or leave a comment in our blog. You can find the document [here](https://newpipe.net/legal/privacy/).
|
||||
…and `ANDROID_NDK_HOME` pointing at NDK r27c (or newer). The Gradle build runs
|
||||
`cargo ndk` + `uniffi-bindgen` automatically.
|
||||
|
||||
## License
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
|
||||
NewPipe is Free Software: You can use, study, share, and improve it at will. Specifically you can redistribute and/or modify it under the terms of the [GNU General Public License](https://www.gnu.org/licenses/gpl.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
GPL-3.0-or-later, inherited from upstream NewPipe.
|
||||
|
||||
## Upstream
|
||||
|
||||
This repo tracks <https://github.com/TeamNewPipe/NewPipe>. Upstream changes
|
||||
get pulled periodically via the `upstream` remote.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Not affiliated with YouTube, Google, NewPipe e.V., the SponsorBlock project,
|
||||
or Return YouTube Dislike. Trademarks belong to their owners. Straw uses
|
||||
public web endpoints; nothing here authenticates to any account.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ moves to Rust.
|
|||
## Build chain
|
||||
|
||||
```
|
||||
crafting-table
|
||||
Build container (Sulkta uses one; any toolchain matching this layout works)
|
||||
├── rustup stable (target add: aarch64-linux-android, armv7-linux-androideabi,
|
||||
│ x86_64-linux-android, i686-linux-android)
|
||||
├── cargo-ndk (cross-compile helper)
|
||||
├── android-sdk (ANDROID_HOME, sdkmanager, build-tools, platforms)
|
||||
└── android-ndk (ANDROID_NDK_HOME, r27c LTS at /caches/android-sdk/ndk/...)
|
||||
└── android-ndk (ANDROID_NDK_HOME, r27c LTS)
|
||||
|
||||
Gradle (strawApp/build.gradle.kts)
|
||||
├── cargoBuild Exec task → cargo ndk -t <abi>... -o jniLibs/ build --release
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ pub struct ChannelInfo {
|
|||
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
pub async fn channel_info(input: String) -> Result<ChannelInfo, StrawcoreError> {
|
||||
log::info!("strawcore::channel_info input={}", input);
|
||||
log::info!("strawcore::channel_info input_len={}", input.len());
|
||||
crate::runtime::ensure_initialized();
|
||||
let identifier = resolve_channel_identifier(&input)?;
|
||||
let core = tokio::task::spawn_blocking(move || core_channel_info(identifier))
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -31,13 +31,47 @@ pub enum StrawcoreError {
|
|||
RequiresLogin { detail: String },
|
||||
}
|
||||
|
||||
/// Drop the `continue=<signed-url>` param from a google.com/sorry/...
|
||||
/// URL while leaving every other param intact. Used only for surfacing
|
||||
/// recaptcha challenge URLs to the UI; keeps the URL tappable for the
|
||||
/// user to solve the challenge while scrubbing the embedded
|
||||
/// googlevideo signature.
|
||||
fn strip_continue_param(url: &str) -> String {
|
||||
let (base, query) = match url.split_once('?') {
|
||||
Some(pair) => pair,
|
||||
None => return url.to_owned(),
|
||||
};
|
||||
let filtered: Vec<&str> = query
|
||||
.split('&')
|
||||
.filter(|kv| {
|
||||
let key = kv.split_once('=').map(|(k, _)| k).unwrap_or(*kv);
|
||||
!key.eq_ignore_ascii_case("continue")
|
||||
})
|
||||
.collect();
|
||||
if filtered.is_empty() {
|
||||
base.to_owned()
|
||||
} else {
|
||||
format!("{}?{}", base, filtered.join("&"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<strawcore_core::exceptions::ExtractionError> for StrawcoreError {
|
||||
fn from(e: strawcore_core::exceptions::ExtractionError) -> Self {
|
||||
use strawcore_core::exceptions::{ContentUnavailable, ExtractionError, NetworkError};
|
||||
match e {
|
||||
ExtractionError::Network(NetworkError::Recaptcha { url }) => {
|
||||
// Strip the `continue=` query param before propagating.
|
||||
// google.com/sorry/index carries the full signed
|
||||
// googlevideo URL in `continue=` so the user can be
|
||||
// sent back to the stream after solving — but
|
||||
// surfacing that to the UI is a credential leak via
|
||||
// screenshot, and Kotlin's LogDump scrubber only
|
||||
// catches googlevideo.com hosts. The challenge URL
|
||||
// itself still solves without `continue=`, so the
|
||||
// user can tap to unblock without leaking the
|
||||
// signature/expire/pot token.
|
||||
StrawcoreError::RequiresLogin {
|
||||
detail: format!("reCAPTCHA at {url}"),
|
||||
detail: format!("reCAPTCHA challenge: {}", strip_continue_param(&url)),
|
||||
}
|
||||
}
|
||||
ExtractionError::Network(NetworkError::Transport(msg)) => {
|
||||
|
|
|
|||
505
rust/strawcore/src/feed.rs
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
// fast subscription feed via YouTube's per-channel RSS endpoint.
|
||||
//
|
||||
// YouTube serves `https://www.youtube.com/feeds/videos.xml?channel_id=UCxxx`
|
||||
// — small Atom XML, no auth, no JS, no InnerTube round-trip. Replaces the
|
||||
// per-channel `channel_info()` page-scrape that was costing ~500ms each
|
||||
// (the bottleneck behind NewPipe's "pull to refresh takes 30 seconds for
|
||||
// 50 subs" UX). Fan-out 50× concurrent via `futures::stream::buffer_unordered`
|
||||
// turns a 50-sub refresh from ~5-8s parallel-12 to ~1s parallel-50.
|
||||
//
|
||||
// RSS is intentionally lossy — it returns title/url/published/thumbnail
|
||||
// only. No duration, no view count, no shorts/age/paid flags. That's the
|
||||
// right trade for a feed-refresh use case: tap-through still goes through
|
||||
// the full stream_info path to fetch the rich metadata when actually
|
||||
// needed.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::{self, StreamExt};
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::error::StrawcoreError;
|
||||
use crate::search::SearchItem;
|
||||
|
||||
const RSS_BASE: &str = "https://www.youtube.com/feeds/videos.xml?channel_id=";
|
||||
const MAX_CONCURRENT: usize = 50;
|
||||
const PER_CHANNEL_TIMEOUT_S: u64 = 8;
|
||||
/// Cap on the body bytes we'll read for a single RSS fetch. Real YT
|
||||
/// Atom feeds are ~5-30 KB; 2 MiB leaves comfortable headroom while
|
||||
/// blocking a hostile or compromised host from streaming GB-scale
|
||||
/// bodies into JVM memory inside the 8s timeout.
|
||||
const RSS_MAX_BYTES: usize = 2 * 1024 * 1024;
|
||||
/// Cap on parsed entries per channel — RSS normally returns 15.
|
||||
/// 50 leaves headroom for one-off legitimate variance; anything
|
||||
/// past that is a sign the feed isn't what we expect.
|
||||
const RSS_MAX_ENTRIES: usize = 50;
|
||||
/// Year range we trust civil-to-days math for. Strawcore RSS only
|
||||
/// emits real-world recent uploads; clamping here turns adversarial
|
||||
/// year fields into a parse failure rather than i64 overflow.
|
||||
const YEAR_MIN: i32 = 1970;
|
||||
const YEAR_MAX: i32 = 2200;
|
||||
|
||||
/// Hybrid-backfill metadata: just the two fields RSS doesn't return
|
||||
/// (view count + duration). Kotlin calls this lazily for visible feed
|
||||
/// items after the RSS-fed paint to fill in the gaps that
|
||||
/// channel_feed_rss leaves empty.
|
||||
///
|
||||
/// built specifically so the subs feed can show 'N views ·
|
||||
/// X duration' the way YT does, without paying the full channel_info
|
||||
/// page-scrape cost on initial paint. The underlying stream_info IS
|
||||
/// heavier than we'd like (~500ms each, runs JS deobf for play URLs
|
||||
/// we'll discard) — future opt would be to parse the watch-page HTML
|
||||
/// JSON state directly for just these two fields. ~100ms savings per
|
||||
/// call but ~150 lines of HTML/JSON pluck logic. Punted until needed.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct EnrichedFeedMetadata {
|
||||
pub view_count: i64,
|
||||
pub duration_seconds: i64,
|
||||
}
|
||||
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
pub async fn enrich_feed_item(
|
||||
video_url: String,
|
||||
) -> Result<EnrichedFeedMetadata, StrawcoreError> {
|
||||
crate::runtime::ensure_initialized();
|
||||
let info = crate::stream::stream_info(video_url).await?;
|
||||
Ok(EnrichedFeedMetadata {
|
||||
view_count: info.view_count,
|
||||
duration_seconds: info.duration_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared reqwest Client — DNS resolver + TLS keepalive + connection
|
||||
/// pool live here so a 50-channel fan-out reuses one pool instead of
|
||||
/// paying 50 handshakes.
|
||||
static RSS_CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
|
||||
fn rss_client() -> Result<&'static Client, StrawcoreError> {
|
||||
if let Some(c) = RSS_CLIENT.get() {
|
||||
return Ok(c);
|
||||
}
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(PER_CHANNEL_TIMEOUT_S))
|
||||
.user_agent(concat!("Mozilla/5.0 (Android; Mobile; Straw/", env!("CARGO_PKG_VERSION"), ")"))
|
||||
// Cap redirect chains so a misconfigured/hostile feed can't
|
||||
// spin a server out of our 8s budget.
|
||||
.redirect(reqwest::redirect::Policy::limited(3))
|
||||
.build()
|
||||
.map_err(|e| StrawcoreError::Extractor {
|
||||
msg: format!("http client build: {e}"),
|
||||
})?;
|
||||
Ok(RSS_CLIENT.get_or_init(|| client))
|
||||
}
|
||||
|
||||
/// Single-channel RSS — Kotlin keeps its per-channel cache + fan-out
|
||||
/// (parallelism cranked to 50 in the wrapper). Each call is ~50-150ms
|
||||
/// instead of the ~500ms channelInfo page-scrape, so a 50-sub refresh
|
||||
/// drops from ~5-8s to ~1s.
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
pub async fn channel_feed_rss(
|
||||
channel_url: String,
|
||||
) -> Result<Vec<SearchItem>, StrawcoreError> {
|
||||
crate::runtime::ensure_initialized();
|
||||
log::info!("strawcore::channel_feed_rss url_len={}", channel_url.len());
|
||||
let client = rss_client()?;
|
||||
Ok(fetch_channel_rss(client, &channel_url).await.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Bulk subscription feed fan-out — for callers that want one round-trip
|
||||
/// to Rust. Currently unused by the Android app (it sticks with the
|
||||
/// per-channel cache), but exposed for future desktop / web variants
|
||||
/// or for a "warm everything" background prefetch.
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
pub async fn subscription_feed(
|
||||
channel_urls: Vec<String>,
|
||||
) -> Result<Vec<SearchItem>, StrawcoreError> {
|
||||
crate::runtime::ensure_initialized();
|
||||
log::info!("strawcore::subscription_feed channels={}", channel_urls.len());
|
||||
if channel_urls.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let client = rss_client()?;
|
||||
|
||||
let results: Vec<Vec<SearchItem>> = stream::iter(channel_urls.into_iter())
|
||||
.map(|url| async move { fetch_channel_rss(client, &url).await.unwrap_or_default() })
|
||||
.buffer_unordered(MAX_CONCURRENT)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
// Per-channel ordering is RSS-served-newest-first. Cross-channel
|
||||
// interleave is the caller's responsibility — Kotlin's mergeFromCache
|
||||
// sorts by parsed recency, which is the source of truth. Returning
|
||||
// the flat list as-is. (an earlier version sorted lexicographically
|
||||
// on the relative-date STRING, which is wrong because "10 hours
|
||||
// ago" < "2 hours ago" in cmp order)
|
||||
Ok(results.into_iter().flatten().collect())
|
||||
}
|
||||
|
||||
async fn fetch_channel_rss(client: &Client, channel_url: &str) -> Option<Vec<SearchItem>> {
|
||||
let channel_id = extract_channel_id(channel_url)?;
|
||||
let url = format!("{RSS_BASE}{channel_id}");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
.error_for_status()
|
||||
.ok()?;
|
||||
// Streaming body read with a hard byte cap — `.text()` reads
|
||||
// unbounded into a String.
|
||||
let body = read_capped_body(resp).await?;
|
||||
parse_rss(&body, channel_id)
|
||||
}
|
||||
|
||||
/// Drain a reqwest Response into a String, bailing out (return None) if
|
||||
/// the body exceeds RSS_MAX_BYTES.
|
||||
async fn read_capped_body(resp: reqwest::Response) -> Option<String> {
|
||||
use futures::StreamExt;
|
||||
let mut total = 0usize;
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(32 * 1024);
|
||||
let mut stream = resp.bytes_stream();
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result.ok()?;
|
||||
// Defense-in-depth: a single hostile chunk can be arbitrarily
|
||||
// large (HTTP allows multi-GiB chunks). Reject any one chunk
|
||||
// bigger than the whole body cap before we even add it to the
|
||||
// running total — protects against hyper having already
|
||||
// allocated the chunk on our behalf.
|
||||
if chunk.len() > RSS_MAX_BYTES {
|
||||
log::warn!("strawcore::rss single chunk {} exceeds cap; aborting", chunk.len());
|
||||
return None;
|
||||
}
|
||||
total = total.saturating_add(chunk.len());
|
||||
if total > RSS_MAX_BYTES {
|
||||
log::warn!("strawcore::rss body exceeded {RSS_MAX_BYTES} bytes; aborting");
|
||||
return None;
|
||||
}
|
||||
buf.extend_from_slice(&chunk);
|
||||
}
|
||||
// Lossy decode — A strict from_utf8
|
||||
// returns None on any invalid byte, so a single mojibake title
|
||||
// would silently drop the entire channel from the feed. quick-xml
|
||||
// tolerates U+FFFD replacement chars and the per-entry skip-on-
|
||||
// empty handles broken entries downstream.
|
||||
Some(String::from_utf8_lossy(&buf).into_owned())
|
||||
}
|
||||
|
||||
/// Extract the `UCxxx` channel ID from a channel URL. Accepts the
|
||||
/// shapes the Android app actually has in Subscriptions plus the ones
|
||||
/// users paste from share intents:
|
||||
/// * `https://www.youtube.com/channel/UCxxx...`
|
||||
/// * `https://youtube.com/channel/UCxxx...`
|
||||
/// * `http(s)://m.youtube.com/channel/UCxxx...`
|
||||
/// * trailing `/videos`, `?si=...`, etc — anything after the ID is dropped
|
||||
/// * raw `UCxxx...` (already an ID)
|
||||
///
|
||||
/// Real YT channel IDs are EXACTLY 24 chars (`UC` + 22 base64-ish).
|
||||
///
|
||||
/// `@handle` URLs are NOT supported here — RSS requires the channel ID.
|
||||
/// Callers with @handles should resolve via channel_info() once and
|
||||
/// cache the ID into Subscriptions.
|
||||
fn extract_channel_id(input: &str) -> Option<String> {
|
||||
let trimmed = input.trim();
|
||||
let trimmed_lower = trimmed.to_lowercase();
|
||||
// Match the "<scheme>://<host>/channel/" prefix in a single sweep
|
||||
// so we accept http/https + www./m. variants without four-way
|
||||
// string-strip ladders. ANCHORED at the start of the string —
|
||||
// prior `find()` accepted any input
|
||||
// containing the prefix as a substring, so a pasted
|
||||
// `evil.com/?redir=https://www.youtube.com/channel/UCxxx` would
|
||||
// silently rewrite to the wrong channel.
|
||||
const PREFIXES: &[&str] = &[
|
||||
"https://www.youtube.com/channel/",
|
||||
"https://youtube.com/channel/",
|
||||
"https://m.youtube.com/channel/",
|
||||
"http://www.youtube.com/channel/",
|
||||
"http://youtube.com/channel/",
|
||||
"http://m.youtube.com/channel/",
|
||||
];
|
||||
for p in PREFIXES {
|
||||
if let Some(rest) = trimmed_lower.strip_prefix(p) {
|
||||
// Bytes match 1:1 with `trimmed` since the prefix is ASCII
|
||||
// and case-folding ASCII doesn't change byte length.
|
||||
let rest_in_original = &trimmed[p.len()..p.len() + rest.len()];
|
||||
let id = rest_in_original
|
||||
.split(|c: char| c == '/' || c == '?' || c == '#')
|
||||
.next()?;
|
||||
return validate_channel_id(id);
|
||||
}
|
||||
}
|
||||
validate_channel_id(trimmed)
|
||||
}
|
||||
|
||||
/// A real YouTube channel ID is `UC` followed by exactly 22 chars from
|
||||
/// `[A-Za-z0-9_-]`.
|
||||
fn validate_channel_id(id: &str) -> Option<String> {
|
||||
if id.len() != 24 || !id.starts_with("UC") {
|
||||
return None;
|
||||
}
|
||||
if !id.bytes().skip(2).all(|b| {
|
||||
matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
Some(id.to_string())
|
||||
}
|
||||
|
||||
fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::Reader;
|
||||
|
||||
let mut reader = Reader::from_str(body);
|
||||
reader.config_mut().trim_text(true);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut items: Vec<SearchItem> = Vec::new();
|
||||
|
||||
// Per-entry scratch.
|
||||
let mut in_entry = false;
|
||||
let mut depth = 0u8;
|
||||
let mut video_id = String::new();
|
||||
let mut title = String::new();
|
||||
let mut uploader = String::new();
|
||||
let mut uploader_url = String::new();
|
||||
let mut thumbnail: Option<String> = None;
|
||||
let mut published = String::new();
|
||||
|
||||
// What text-collecting state we're in. Replaced per element open.
|
||||
let mut text_target: Option<TextTarget> = None;
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e)) => {
|
||||
let name = e.name();
|
||||
let local = local_name(name.as_ref());
|
||||
if local == "entry" {
|
||||
in_entry = true;
|
||||
depth = 0;
|
||||
video_id.clear();
|
||||
title.clear();
|
||||
uploader.clear();
|
||||
uploader_url.clear();
|
||||
thumbnail = None;
|
||||
published.clear();
|
||||
}
|
||||
if !in_entry {
|
||||
continue;
|
||||
}
|
||||
depth = depth.saturating_add(1);
|
||||
text_target = match local {
|
||||
"videoId" => Some(TextTarget::VideoId),
|
||||
"title" if depth <= 2 => Some(TextTarget::Title),
|
||||
"name" => Some(TextTarget::UploaderName),
|
||||
"uri" => Some(TextTarget::UploaderUrl),
|
||||
"published" => Some(TextTarget::Published),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
Ok(Event::Empty(e)) => {
|
||||
if !in_entry {
|
||||
continue;
|
||||
}
|
||||
let name = e.name();
|
||||
let local = local_name(name.as_ref());
|
||||
// <media:thumbnail url="..."/> is self-closing.
|
||||
if local == "thumbnail" {
|
||||
for attr in e.attributes().flatten() {
|
||||
if attr.key.as_ref() == b"url" {
|
||||
if let Ok(v) = attr.unescape_value() {
|
||||
thumbnail = Some(v.into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Text(t)) => {
|
||||
if !in_entry {
|
||||
continue;
|
||||
}
|
||||
let Ok(s) = t.unescape() else { continue };
|
||||
let s = s.as_ref();
|
||||
match text_target {
|
||||
Some(TextTarget::VideoId) => video_id.push_str(s),
|
||||
Some(TextTarget::Title) => title.push_str(s),
|
||||
Some(TextTarget::UploaderName) => uploader.push_str(s),
|
||||
Some(TextTarget::UploaderUrl) => uploader_url.push_str(s),
|
||||
Some(TextTarget::Published) => published.push_str(s),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Ok(Event::End(e)) => {
|
||||
if !in_entry {
|
||||
continue;
|
||||
}
|
||||
let name = e.name();
|
||||
let local = local_name(name.as_ref());
|
||||
if local == "entry" {
|
||||
// Skip entries missing the load-bearing fields —
|
||||
// an empty title renders as a blank card the user
|
||||
// can't tap, and an empty published collapses the
|
||||
// recency sort.
|
||||
if !video_id.is_empty() && !title.is_empty() && !published.is_empty() {
|
||||
items.push(SearchItem {
|
||||
url: format!("https://www.youtube.com/watch?v={video_id}"),
|
||||
title: title.clone(),
|
||||
uploader: uploader.clone(),
|
||||
uploader_url: if uploader_url.is_empty() {
|
||||
Some(format!("https://www.youtube.com/channel/{channel_id}"))
|
||||
} else {
|
||||
Some(uploader_url.clone())
|
||||
},
|
||||
thumbnail: thumbnail.clone(),
|
||||
duration_seconds: 0,
|
||||
view_count: 0,
|
||||
// RSS gives RFC3339 timestamps. Convert to
|
||||
// the human-relative format Kotlin's
|
||||
// recencyScore parser expects ("N units
|
||||
// ago"). An earlier build was passing the raw ISO
|
||||
// through, which broke the sort comparator
|
||||
// — every item tied at MIN_VALUE so the
|
||||
// feed order was effectively random; LTT +
|
||||
// WTYP landed at top because they resolved
|
||||
// first in the fan-out. Caught 2026-05-26.
|
||||
upload_date_relative: iso_to_relative(&published),
|
||||
});
|
||||
if items.len() >= RSS_MAX_ENTRIES {
|
||||
// Defense-in-depth against a feed that
|
||||
// ships thousands of <entry> blocks.
|
||||
return Some(items);
|
||||
}
|
||||
}
|
||||
in_entry = false;
|
||||
depth = 0;
|
||||
} else {
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
text_target = None;
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
// Partial-parse on error: return whatever we've already
|
||||
// collected rather than throwing the whole batch away.
|
||||
// A truncated body (EOF mid-stream on a flaky network)
|
||||
// would otherwise silently disappear the channel.
|
||||
Err(e) => {
|
||||
log::warn!("strawcore::rss parse error after {} items: {e}", items.len());
|
||||
return Some(items);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
Some(items)
|
||||
}
|
||||
|
||||
enum TextTarget {
|
||||
VideoId,
|
||||
Title,
|
||||
UploaderName,
|
||||
UploaderUrl,
|
||||
Published,
|
||||
}
|
||||
|
||||
/// Parse an RFC3339 timestamp (`2026-05-25T15:00:00+00:00`) into "N
|
||||
/// units ago". Drops the timezone offset — YT RSS always serves UTC
|
||||
/// and the granularity is days at most, so a ±14h skew doesn't matter
|
||||
/// for the relative display.
|
||||
///
|
||||
/// Falls back to the raw string if parsing fails. That keeps the UI
|
||||
/// readable even on a malformed feed (rare).
|
||||
fn iso_to_relative(iso: &str) -> String {
|
||||
let secs = match parse_rfc3339_secs(iso) {
|
||||
Some(s) => s,
|
||||
None => return iso.to_string(),
|
||||
};
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
// A device with a skewed clock can see RSS timestamps as future-
|
||||
// dated. saturating_sub returns 0 → "0 seconds ago" → sorts to
|
||||
// top, which is the LTT/WTYP-recurrence vector. Treat future
|
||||
// dates as "just now" so the relative-string sort behaves and
|
||||
// a single skewed item doesn't pin itself at the top of the
|
||||
// feed.
|
||||
if secs > now_secs {
|
||||
return "just now".to_string();
|
||||
}
|
||||
format_relative(now_secs - secs)
|
||||
}
|
||||
|
||||
fn parse_rfc3339_secs(s: &str) -> Option<i64> {
|
||||
if s.len() < 19 {
|
||||
return None;
|
||||
}
|
||||
let date = s.get(..10)?;
|
||||
let time = s.get(11..19)?;
|
||||
if !s.is_char_boundary(10) || s.as_bytes().get(10) != Some(&b'T') {
|
||||
return None;
|
||||
}
|
||||
let mut date_parts = date.split('-');
|
||||
let y: i32 = date_parts.next()?.parse().ok()?;
|
||||
let m: u32 = date_parts.next()?.parse().ok()?;
|
||||
let d: u32 = date_parts.next()?.parse().ok()?;
|
||||
let mut time_parts = time.split(':');
|
||||
let hh: u32 = time_parts.next()?.parse().ok()?;
|
||||
let mm: u32 = time_parts.next()?.parse().ok()?;
|
||||
let ss: u32 = time_parts.next()?.parse().ok()?;
|
||||
// Year clamp BEFORE civil_to_days — out-of-range years overflow
|
||||
// the era arithmetic in debug, wrap in release. A hostile feed
|
||||
// serving year=2147483647 must not produce junk timestamps.
|
||||
if !(YEAR_MIN..=YEAR_MAX).contains(&y) {
|
||||
return None;
|
||||
}
|
||||
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 60 {
|
||||
return None;
|
||||
}
|
||||
let days = civil_to_days(y, m, d);
|
||||
Some(days * 86_400 + hh as i64 * 3_600 + mm as i64 * 60 + ss as i64)
|
||||
}
|
||||
|
||||
/// Howard Hinnant's days-since-1970-01-01 algorithm. Standard,
|
||||
/// branch-free, handles negative years correctly. Source: chrono
|
||||
/// proposal for C++20.
|
||||
fn civil_to_days(y: i32, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = if y >= 0 { y / 400 } else { (y - 399) / 400 };
|
||||
let yoe = (y - era * 400) as u32;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era as i64 * 146_097 + doe as i64 - 719_468
|
||||
}
|
||||
|
||||
fn format_relative(age_secs: i64) -> String {
|
||||
let s = age_secs.max(0);
|
||||
fn unit(n: i64, name: &str) -> String {
|
||||
format!("{} {}{} ago", n, name, if n == 1 { "" } else { "s" })
|
||||
}
|
||||
if s < 60 {
|
||||
unit(s, "second")
|
||||
} else if s < 3_600 {
|
||||
unit(s / 60, "minute")
|
||||
} else if s < 86_400 {
|
||||
unit(s / 3_600, "hour")
|
||||
} else if s < 604_800 {
|
||||
unit(s / 86_400, "day")
|
||||
} else if s < 2_592_000 {
|
||||
unit(s / 604_800, "week")
|
||||
} else if s < 31_536_000 {
|
||||
unit(s / 2_592_000, "month")
|
||||
} else {
|
||||
unit(s / 31_536_000, "year")
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the namespace prefix off an XML element name. YouTube's feed
|
||||
/// is heavily namespaced (`yt:videoId`, `media:thumbnail`) but we only
|
||||
/// care about the local part — namespace-vs-local distinguishing
|
||||
/// would just bloat the matcher.
|
||||
fn local_name(qualified: &[u8]) -> &str {
|
||||
let s = std::str::from_utf8(qualified).unwrap_or("");
|
||||
match s.rfind(':') {
|
||||
Some(idx) => &s[idx + 1..],
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ pub struct SearchItem {
|
|||
pub duration_seconds: i64,
|
||||
/// Reported view count. 0 = unknown.
|
||||
pub view_count: i64,
|
||||
/// Relative upload date as YT renders it ("2 days ago", "3 weeks
|
||||
/// ago"). Empty if not extracted. Strawcore-core already populates
|
||||
/// this on StreamInfoItem; we just pass it through.
|
||||
pub upload_date_relative: String,
|
||||
}
|
||||
|
||||
pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
|
||||
|
|
@ -44,12 +48,23 @@ pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
|
|||
} else {
|
||||
item.view_count
|
||||
},
|
||||
upload_date_relative: item.upload_date_relative,
|
||||
}
|
||||
}
|
||||
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
pub async fn search(query: String) -> Result<Vec<SearchItem>, StrawcoreError> {
|
||||
log::info!("strawcore::search query={}", query);
|
||||
// Don't log the query itself — searches are PII (sometimes
|
||||
// names, sometimes embarrassing) and android_logger emits at
|
||||
// info-level in release builds, which means they'd ride the
|
||||
// Settings → Export Logs path straight into a user's chat. Log
|
||||
// shape, not content.
|
||||
log::info!("strawcore::search query_len={}", query.len());
|
||||
// ensure_initialized was only wired into
|
||||
// init_logging() so the 5s-backoff retry path never fired from
|
||||
// the hot entry points. Now every extractor entry re-asserts
|
||||
// — cheap when INITIALIZED is true (single Acquire load).
|
||||
crate::runtime::ensure_initialized();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
search_extractor::search(&query, SearchFilter::Videos)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@ pub struct AudioStreamItem {
|
|||
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
pub async fn stream_info(input: String) -> Result<StreamInfo, StrawcoreError> {
|
||||
log::info!("strawcore::stream_info input={}", input);
|
||||
log::info!("strawcore::stream_info input_len={}", input.len());
|
||||
crate::runtime::ensure_initialized();
|
||||
let video_id = resolve_video_id(&input)?;
|
||||
let video_id_for_call = video_id.clone();
|
||||
let core = tokio::task::spawn_blocking(move || core_stream_info(&video_id_for_call))
|
||||
|
|
|
|||
|
|
@ -39,13 +39,30 @@ configure<ApplicationExtension> {
|
|||
}
|
||||
|
||||
buildTypes {
|
||||
// R8 enabled on BOTH variants — we publish the debug APK to
|
||||
// fdroid (com.sulkta.straw.debug) per the existing pipeline,
|
||||
// and audit-flagged Log.d strips depended on R8 actually
|
||||
// running on the variant we ship. Keep rules in
|
||||
// strawApp/proguard-rules.pro cover UniFFI + JNA +
|
||||
// kotlinx-serialization companions.
|
||||
debug {
|
||||
isDebuggable = true
|
||||
applicationIdSuffix = ".debug"
|
||||
resValue("string", "app_name", "Straw debug")
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +98,7 @@ dependencies {
|
|||
implementation(libs.jetbrains.compose.foundation)
|
||||
implementation(libs.jetbrains.compose.material3)
|
||||
implementation(libs.jetbrains.compose.ui)
|
||||
implementation("androidx.compose.material:material-icons-core:1.7.5")
|
||||
implementation("androidx.compose.material:material-icons-extended:1.7.5")
|
||||
|
||||
// Lifecycle + ViewModel for Compose
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
||||
|
|
@ -95,7 +112,9 @@ dependencies {
|
|||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
// NewPipeExtractor (JVM/Android-only) + its OkHttp dep
|
||||
implementation(libs.newpipe.extractor)
|
||||
// libs.newpipe.extractor — REMOVED in Path C-6. Extractor is now strawcore
|
||||
// (Rust + rustypipe via UniFFI). See rust/strawcore/ + the cargoBuild +
|
||||
// uniffiBindgen Gradle tasks below.
|
||||
implementation(libs.squareup.okhttp)
|
||||
|
||||
// JSON for SponsorBlock + Return YouTube Dislike clients
|
||||
|
|
@ -110,4 +129,101 @@ dependencies {
|
|||
implementation("androidx.media3:media3-session:1.4.1")
|
||||
// Guava ListenableFuture support for awaiting MediaController connect.
|
||||
implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0")
|
||||
|
||||
// WorkManager — periodic background poll of fdroid.sulkta.com index
|
||||
// for self-update notifications. CoroutineWorker is built into the
|
||||
// base work-runtime artifact as of 2.10.
|
||||
implementation(libs.androidx.work.runtime)
|
||||
|
||||
// strawcore — Rust YouTube extractor via UniFFI/JNA. Built by the
|
||||
// cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search /
|
||||
// streamInfo / channelInfo to replace NewPipeExtractor.
|
||||
implementation("net.java.dev.jna:jna:5.14.0@aar")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase U-1 / Path-C-2 — Rust core build glue.
|
||||
//
|
||||
// Two tasks chain into the Android build:
|
||||
// cargoBuild — cross-compiles rust/strawcore for the four Android ABIs
|
||||
// via cargo-ndk and drops the .so files in strawApp/src/main/jniLibs/.
|
||||
// uniffiBindgen — generates the Kotlin bindings from the freshly-built lib
|
||||
// into strawApp/src/main/java/uniffi/strawcore/.
|
||||
//
|
||||
// Both depend on:
|
||||
// - cargo + rustup with the four Android targets installed
|
||||
// - cargo-ndk on PATH
|
||||
// - ANDROID_NDK_HOME pointing at an NDK with the right toolchains
|
||||
// All of that lives in the Sulkta build container.
|
||||
// =============================================================================
|
||||
|
||||
val rustRoot = file("../rust").absolutePath
|
||||
val jniLibsDir = file("src/main/jniLibs").absolutePath
|
||||
val bindingsDir = file("src/main/java").absolutePath
|
||||
|
||||
val cargoHome: String = System.getenv("CARGO_HOME") ?: "/caches/cargo"
|
||||
val cargoBin: String = "$cargoHome/bin/cargo"
|
||||
val ndkHome: String = System.getenv("ANDROID_NDK_HOME")
|
||||
?: System.getenv("ANDROID_NDK_ROOT")
|
||||
?: "/caches/android-sdk/ndk/27.2.12479018"
|
||||
// Honor CARGO_TARGET_DIR if set (our build container redirects it to a
|
||||
// cache mount because the container's writable rootfs hits 100% before
|
||||
// the cross-compile for 4 ABIs finishes). Falls back to the default
|
||||
// `<workspace>/target`.
|
||||
val cargoTargetDir: String = System.getenv("CARGO_TARGET_DIR")
|
||||
?: "$rustRoot/target"
|
||||
|
||||
val cargoBuild by tasks.registering(Exec::class) {
|
||||
group = "rust"
|
||||
description = "Cross-compile strawcore for all Android ABIs via cargo-ndk."
|
||||
workingDir = file(rustRoot)
|
||||
environment("ANDROID_NDK_HOME", ndkHome)
|
||||
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
|
||||
commandLine = listOf(
|
||||
cargoBin, "ndk",
|
||||
"-t", "arm64-v8a",
|
||||
"-t", "armeabi-v7a",
|
||||
"-t", "x86",
|
||||
"-t", "x86_64",
|
||||
"-o", jniLibsDir,
|
||||
"build", "--release", "-p", "strawcore",
|
||||
)
|
||||
standardOutput = System.out
|
||||
errorOutput = System.err
|
||||
}
|
||||
|
||||
val cargoBuildHost by tasks.registering(Exec::class) {
|
||||
group = "rust"
|
||||
description = "Build host-arch debug strawcore so bindgen can read its UniFFI metadata."
|
||||
workingDir = file(rustRoot)
|
||||
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
|
||||
commandLine = listOf(cargoBin, "build", "-p", "strawcore")
|
||||
standardOutput = System.out
|
||||
errorOutput = System.err
|
||||
}
|
||||
|
||||
val uniffiBindgen by tasks.registering(Exec::class) {
|
||||
group = "rust"
|
||||
description = "Generate Kotlin bindings for strawcore via uniffi-bindgen."
|
||||
dependsOn(cargoBuildHost)
|
||||
workingDir = file(rustRoot)
|
||||
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
|
||||
commandLine = listOf(
|
||||
cargoBin, "run", "--quiet", "--bin", "uniffi-bindgen", "--",
|
||||
"generate",
|
||||
"--library", "$cargoTargetDir/debug/libstrawcore.so",
|
||||
"--crate", "strawcore",
|
||||
"--language", "kotlin",
|
||||
"--no-format",
|
||||
"--out-dir", bindingsDir,
|
||||
)
|
||||
standardOutput = System.out
|
||||
errorOutput = System.err
|
||||
}
|
||||
|
||||
// Make sure Android's JNI-libs merge picks up the freshly built .so files,
|
||||
// and Kotlin compilation can resolve the generated bindings.
|
||||
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }
|
||||
.configureEach { dependsOn(cargoBuild) }
|
||||
tasks.matching { it.name.startsWith("compile") && it.name.endsWith("Kotlin") }
|
||||
.configureEach { dependsOn(uniffiBindgen) }
|
||||
|
|
|
|||
90
strawApp/proguard-rules.pro
vendored
Normal file
|
|
@ -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 { *; }
|
||||
|
|
@ -11,12 +11,20 @@
|
|||
<!-- Wake while audio plays -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- DownloadManager Request.setNotificationVisibility(HIDDEN) requires
|
||||
this permission. Used by Downloader so signed googlevideo URLs
|
||||
don't surface in the system notification shade. -->
|
||||
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
|
||||
|
||||
<application
|
||||
android:name=".StrawApp"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@android:drawable/sym_def_app_icon"
|
||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<activity
|
||||
|
|
@ -29,7 +37,12 @@
|
|||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Open YouTube URLs with Straw. -->
|
||||
<!-- Open YouTube URLs with Straw. Hosts here must stay in sync
|
||||
with ALLOWED_YT_HOSTS in util/YtUrl.kt (canonical home).
|
||||
Was previously inlined in StrawActivity.kt under YT_HOSTS;
|
||||
the two lists drifted (music.youtube.com etc. accepted by
|
||||
code but never offered by the launcher disambig), so the
|
||||
canonical list lives in one place now. -->
|
||||
<intent-filter android:autoVerify="false">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
|
@ -39,6 +52,9 @@
|
|||
<data android:host="m.youtube.com" />
|
||||
<data android:host="youtube.com" />
|
||||
<data android:host="youtu.be" />
|
||||
<data android:host="music.youtube.com" />
|
||||
<data android:host="youtube-nocookie.com" />
|
||||
<data android:host="www.youtube-nocookie.com" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
|
@ -47,11 +63,11 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Phase M-2 / S: MediaSessionService for background audio + notification + lock-screen
|
||||
controls. Marked NOT exported (audit CRIT-2): any installed app can otherwise
|
||||
craft an Intent with the MediaSessionService action and drive playback from
|
||||
attacker-controlled URLs. The intent-filter stays so the Media3 session router
|
||||
can find the service within our own process. -->
|
||||
<!-- MediaSessionService for background audio + notification + lock-screen
|
||||
controls. Marked NOT exported: otherwise any installed app could
|
||||
craft an Intent with the MediaSessionService action and drive playback
|
||||
from attacker-controlled URLs. The intent-filter stays so the Media3
|
||||
session router can find the service within our own process. -->
|
||||
<service
|
||||
android:name=".feature.player.PlaybackService"
|
||||
android:exported="false"
|
||||
|
|
@ -60,5 +76,16 @@
|
|||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- FileProvider for sharing log dumps from Settings → Export logs. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -12,29 +12,54 @@ import androidx.activity.OnBackPressedCallback
|
|||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.data.ThemeMode
|
||||
import com.sulkta.straw.feature.channel.ChannelScreen
|
||||
import com.sulkta.straw.feature.detail.VideoDetailScreen
|
||||
import com.sulkta.straw.feature.download.DownloadsScreen
|
||||
import com.sulkta.straw.feature.player.LocalStrawController
|
||||
import com.sulkta.straw.feature.player.MinibarOverlay
|
||||
import com.sulkta.straw.feature.player.NowPlaying
|
||||
import com.sulkta.straw.feature.player.PlayerScreen
|
||||
import com.sulkta.straw.feature.player.SponsorBlockSkipLoop
|
||||
import com.sulkta.straw.feature.player.rememberStrawController
|
||||
import com.sulkta.straw.feature.playlist.PlaylistViewScreen
|
||||
import com.sulkta.straw.feature.playlist.PlaylistsScreen
|
||||
import com.sulkta.straw.feature.search.SearchScreen
|
||||
import com.sulkta.straw.feature.settings.SettingsScreen
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
private val YT_HOSTS = setOf(
|
||||
"youtube.com", "www.youtube.com", "m.youtube.com",
|
||||
"music.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com",
|
||||
"youtu.be",
|
||||
)
|
||||
// Allowlist now lives in util/YtUrl.kt with extra hardening (scheme
|
||||
// requirement, trailing-dot strip). The prior shape duplicated the
|
||||
// host set here and would drift away from the util.
|
||||
private val YT_URL_RE = Regex(
|
||||
"https?://(?:www\\.|m\\.|music\\.)?(?:youtube(?:-nocookie)?\\.com/[A-Za-z0-9_/?=&\\-.%]+|youtu\\.be/[A-Za-z0-9_\\-]+)",
|
||||
)
|
||||
|
||||
class StrawActivity : ComponentActivity() {
|
||||
|
||||
/**
|
||||
* Newly-arrived deep-link URL while the activity is already running.
|
||||
* `onNewIntent` writes here; the Compose tree observes and pushes a
|
||||
* VideoDetail screen. Without this the singleTask flag silently drops
|
||||
* every share-to-Straw after the first.
|
||||
*/
|
||||
private val pendingDeepLink = MutableStateFlow<String?>(null)
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -42,8 +67,22 @@ class StrawActivity : ComponentActivity() {
|
|||
val startUrl = pickYouTubeUrl(intent)
|
||||
|
||||
setContent {
|
||||
val scheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
|
||||
// Theme picker: System follows OS, Light/Dark force the
|
||||
// matching scheme regardless of system setting.
|
||||
val themeMode by Settings.get().themeMode.collectAsState()
|
||||
val systemDark = isSystemInDarkTheme()
|
||||
val dark = when (themeMode) {
|
||||
ThemeMode.System -> systemDark
|
||||
ThemeMode.Light -> false
|
||||
ThemeMode.Dark -> true
|
||||
}
|
||||
val scheme = if (dark) strawDarkColors() else strawLightColors()
|
||||
// One MediaController for the whole activity. Every screen pulls
|
||||
// it via LocalStrawController; the minibar overlay below uses it
|
||||
// too. Single player, single source of truth.
|
||||
val controller = rememberStrawController()
|
||||
MaterialTheme(colorScheme = scheme) {
|
||||
CompositionLocalProvider(LocalStrawController provides controller) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
val initial: Screen =
|
||||
if (startUrl != null) Screen.VideoDetail(startUrl, "") else Screen.Home
|
||||
|
|
@ -62,71 +101,126 @@ class StrawActivity : ComponentActivity() {
|
|||
onDispose { cb.remove() }
|
||||
}
|
||||
|
||||
when (val s = nav.current) {
|
||||
is Screen.Home -> StrawHome(
|
||||
onOpenSearch = { nav.push(Screen.Search) },
|
||||
onOpenSettings = { nav.push(Screen.Settings) },
|
||||
onOpenVideo = { url, title ->
|
||||
nav.push(Screen.VideoDetail(url, title))
|
||||
},
|
||||
onOpenChannel = { url, name ->
|
||||
nav.push(Screen.Channel(url, name))
|
||||
},
|
||||
)
|
||||
is Screen.Settings -> SettingsScreen()
|
||||
is Screen.Search -> SearchScreen(
|
||||
onOpenVideo = { url, title ->
|
||||
nav.push(Screen.VideoDetail(url, title))
|
||||
},
|
||||
)
|
||||
is Screen.VideoDetail -> VideoDetailScreen(
|
||||
streamUrl = s.streamUrl,
|
||||
initialTitle = s.title,
|
||||
onPlay = {
|
||||
nav.push(Screen.Player(s.streamUrl, s.title))
|
||||
},
|
||||
onOpenChannel = { url, name ->
|
||||
nav.push(Screen.Channel(url, name))
|
||||
},
|
||||
onOpenVideo = { url, title ->
|
||||
nav.push(Screen.VideoDetail(url, title))
|
||||
},
|
||||
)
|
||||
is Screen.Channel -> ChannelScreen(
|
||||
channelUrl = s.channelUrl,
|
||||
initialName = s.name,
|
||||
onOpenVideo = { url, title ->
|
||||
nav.push(Screen.VideoDetail(url, title))
|
||||
},
|
||||
)
|
||||
is Screen.Player -> PlayerScreen(
|
||||
streamUrl = s.streamUrl,
|
||||
title = s.title,
|
||||
)
|
||||
// Drain newly-arrived deep links. Consumed (cleared) once
|
||||
// pushed so we don't re-navigate on every recomposition.
|
||||
val pending by pendingDeepLink.collectAsState()
|
||||
LaunchedEffect(pending) {
|
||||
val url = pending ?: return@LaunchedEffect
|
||||
nav.push(Screen.VideoDetail(url, ""))
|
||||
pendingDeepLink.value = null
|
||||
}
|
||||
|
||||
// SponsorBlock skip loop runs at the activity level so it
|
||||
// applies whether the user is fullscreen, in the minibar,
|
||||
// or away from the player surface.
|
||||
SponsorBlockSkipLoop()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ScreenContent(nav, s = nav.current)
|
||||
// The minibar is the takeover-when-you-leave UI:
|
||||
// hide it while you're on the actual video page
|
||||
// (the inline player IS the player) and hide it
|
||||
// in fullscreen (which IS the player). Everywhere
|
||||
// else, audio keeps going and the minibar gives
|
||||
// you a way back.
|
||||
val cur = nav.current
|
||||
if (cur !is Screen.Player && cur !is Screen.VideoDetail) {
|
||||
MinibarOverlay(
|
||||
onExpand = {
|
||||
val item = NowPlaying.current.value ?: return@MinibarOverlay
|
||||
nav.push(Screen.VideoDetail(item.streamUrl, item.title))
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull a YouTube URL out of an incoming Intent (VIEW or SEND). */
|
||||
/**
|
||||
* `launchMode="singleTask"` means a fresh VIEW/SEND from Chrome lands
|
||||
* on the already-running activity instead of creating a new instance.
|
||||
* Forward the URL into the Compose tree via the pending-link flow.
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
pickYouTubeUrl(intent)?.let { pendingDeepLink.value = it }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenContent(nav: Navigator, s: Screen) {
|
||||
when (s) {
|
||||
is Screen.Home -> StrawHome(
|
||||
onOpenSearch = { nav.push(Screen.Search) },
|
||||
onOpenSettings = { nav.push(Screen.Settings) },
|
||||
onOpenPlaylists = { nav.push(Screen.Playlists) },
|
||||
onOpenDownloads = { nav.push(Screen.Downloads) },
|
||||
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
|
||||
onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
|
||||
)
|
||||
is Screen.Downloads -> DownloadsScreen()
|
||||
is Screen.Settings -> SettingsScreen()
|
||||
is Screen.Search -> SearchScreen(
|
||||
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
|
||||
onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
|
||||
)
|
||||
is Screen.VideoDetail -> VideoDetailScreen(
|
||||
streamUrl = s.streamUrl,
|
||||
initialTitle = s.title,
|
||||
onPlay = { nav.push(Screen.Player(s.streamUrl, s.title)) },
|
||||
onMinimize = { if (!nav.pop()) nav.resetTo(Screen.Home) },
|
||||
onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
|
||||
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
|
||||
)
|
||||
is Screen.Channel -> ChannelScreen(
|
||||
channelUrl = s.channelUrl,
|
||||
initialName = s.name,
|
||||
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
|
||||
)
|
||||
is Screen.Player -> PlayerScreen(
|
||||
streamUrl = s.streamUrl,
|
||||
title = s.title,
|
||||
onMinimize = { nav.pop() },
|
||||
)
|
||||
is Screen.Playlists -> PlaylistsScreen(
|
||||
onOpenPlaylist = { id, name -> nav.push(Screen.PlaylistView(id, name)) },
|
||||
)
|
||||
is Screen.PlaylistView -> PlaylistViewScreen(
|
||||
playlistId = s.playlistId,
|
||||
initialName = s.name,
|
||||
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull a YouTube URL out of an incoming VIEW or SEND intent. */
|
||||
private fun pickYouTubeUrl(intent: Intent?): String? {
|
||||
intent ?: return null
|
||||
return when (intent.action) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
val data = intent.data?.toString() ?: return null
|
||||
// Explicit scheme + host check — defense in depth vs the
|
||||
// manifest intent-filter (apps can synth intents that
|
||||
// bypass filter scheme matching when activity is exported).
|
||||
if (intent.scheme?.lowercase() !in setOf("https", "http")) return null
|
||||
// manifest intent-filter; apps can synth intents that
|
||||
// bypass filter scheme matching on exported activities.
|
||||
// HTTPS only — matches the manifest VIEW filter so an explicit
|
||||
// ComponentName intent can't smuggle an http:// URL past the
|
||||
// filter check. Defense in depth; the YT_URL_RE still allows
|
||||
// http for the ACTION_SEND substring case where the URL is
|
||||
// embedded in attacker-controlled text and we want to match
|
||||
// common share-sheet links, but VIEW must be tighter.
|
||||
if (intent.scheme?.lowercase() != "https") return null
|
||||
if (!looksLikeYouTube(data)) return null
|
||||
data
|
||||
}
|
||||
Intent.ACTION_SEND -> {
|
||||
val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null
|
||||
// Regex extracts a YT-looking substring from arbitrary
|
||||
// attacker-controlled text. Re-validate via URI parse + host
|
||||
// check before we hand it to NewPipeExtractor.
|
||||
// Extract a YT-looking substring from attacker-controlled
|
||||
// text, then re-validate via URI parse + host check before
|
||||
// handing it to the extractor.
|
||||
val candidate = YT_URL_RE.find(shared)?.value ?: return null
|
||||
val truncated = candidate.substringBefore('#').trim()
|
||||
if (!looksLikeYouTube(truncated)) return null
|
||||
|
|
@ -136,8 +230,6 @@ class StrawActivity : ComponentActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun looksLikeYouTube(url: String): Boolean {
|
||||
val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return false
|
||||
return host.lowercase() in YT_HOSTS
|
||||
}
|
||||
private fun looksLikeYouTube(url: String): Boolean =
|
||||
com.sulkta.straw.util.isAllowedYtUrl(url)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@
|
|||
|
||||
package com.sulkta.straw
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -22,13 +26,22 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PlaylistPlay
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -36,23 +49,23 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
|
@ -67,7 +80,11 @@ import com.sulkta.straw.data.History
|
|||
import com.sulkta.straw.data.Subscriptions
|
||||
import com.sulkta.straw.data.WatchHistoryItem
|
||||
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
|
||||
import com.sulkta.straw.feature.player.VideoThumbnail
|
||||
import com.sulkta.straw.feature.playlist.VideoActionTarget
|
||||
import com.sulkta.straw.feature.playlist.VideoActionsSheet
|
||||
import com.sulkta.straw.feature.search.StreamItem
|
||||
import com.sulkta.straw.util.rememberBottomContentPadding
|
||||
import com.sulkta.straw.util.formatViews
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -78,6 +95,8 @@ private enum class HomeView { Subs, History }
|
|||
fun StrawHome(
|
||||
onOpenSearch: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenPlaylists: () -> Unit,
|
||||
onOpenDownloads: () -> Unit,
|
||||
onOpenVideo: (url: String, title: String) -> Unit,
|
||||
onOpenChannel: (channelUrl: String, name: String) -> Unit,
|
||||
feedVm: SubscriptionFeedViewModel = viewModel(),
|
||||
|
|
@ -107,7 +126,7 @@ fun StrawHome(
|
|||
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Subscriptions") },
|
||||
icon = { Text("👤") },
|
||||
icon = { Icon(Icons.Filled.Person, contentDescription = null) },
|
||||
selected = view == HomeView.Subs,
|
||||
onClick = {
|
||||
view = HomeView.Subs
|
||||
|
|
@ -117,7 +136,7 @@ fun StrawHome(
|
|||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("History") },
|
||||
icon = { Text("📺") },
|
||||
icon = { Icon(Icons.Filled.History, contentDescription = null) },
|
||||
selected = view == HomeView.History,
|
||||
onClick = {
|
||||
view = HomeView.History
|
||||
|
|
@ -125,10 +144,30 @@ fun StrawHome(
|
|||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Playlists") },
|
||||
icon = { Icon(Icons.Filled.PlaylistPlay, contentDescription = null) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
onOpenPlaylists()
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Downloads") },
|
||||
icon = { Icon(Icons.Filled.Download, contentDescription = null) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
onOpenDownloads()
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Settings") },
|
||||
icon = { Text("⚙") },
|
||||
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
|
|
@ -141,42 +180,33 @@ fun StrawHome(
|
|||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
// Green-tinted bar inspired by NewPipe/Tubular's colored
|
||||
// header, but using our forest-green primary container so
|
||||
// it sits cleanly with the rest of the Material 3 surfaces.
|
||||
TopAppBar(
|
||||
title = {
|
||||
// Search-pill in the title slot — tap takes you to the
|
||||
// full search screen with the field auto-focused. Same
|
||||
// idea as YT's mobile top bar.
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 8.dp)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.clickable(onClick = onOpenSearch),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 14.dp),
|
||||
) {
|
||||
Text(
|
||||
"🔍",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
"Search YouTube",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"straw",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = "Menu")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(Icons.Filled.Search, contentDescription = "Search")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
|
|
@ -208,6 +238,10 @@ fun StrawHome(
|
|||
@Composable
|
||||
private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) {
|
||||
val watches by History.get().watches.collectAsState()
|
||||
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
|
||||
actionTarget?.let { t ->
|
||||
VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(
|
||||
|
|
@ -224,9 +258,20 @@ private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) {
|
|||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
LazyColumn(contentPadding = rememberBottomContentPadding()) {
|
||||
items(watches) { w ->
|
||||
RecentRow(w) { onOpenVideo(w.url, w.title) }
|
||||
RecentRow(
|
||||
item = w,
|
||||
onClick = { onOpenVideo(w.url, w.title) },
|
||||
onLongClick = {
|
||||
actionTarget = VideoActionTarget(
|
||||
streamUrl = w.url,
|
||||
title = w.title,
|
||||
uploader = w.uploader,
|
||||
thumbnail = w.thumbnail,
|
||||
)
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
|
@ -242,8 +287,49 @@ private fun SubsPane(
|
|||
) {
|
||||
val subs by Subscriptions.get().subs.collectAsState()
|
||||
val feed by feedVm.ui.collectAsState()
|
||||
val watches by History.get().watches.collectAsState()
|
||||
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
|
||||
actionTarget?.let { t ->
|
||||
VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
|
||||
}
|
||||
LaunchedEffect(subs) { feedVm.refreshIfStale() }
|
||||
|
||||
// Filter + pagination state. hideWatched is sticky for the session
|
||||
// (no SharedPreferences yet — easy to add if persistence is wanted).
|
||||
// visibleCount starts at PAGE_SIZE and grows by PAGE_SIZE every time
|
||||
// the scroll passes ~5 items from the bottom of what's currently
|
||||
// visible.
|
||||
var hideWatched by remember { mutableStateOf(false) }
|
||||
var visibleCount by remember { mutableIntStateOf(PAGE_SIZE) }
|
||||
|
||||
// O(1) lookup for the watched-filter; rebuild only when watches
|
||||
// change. Drop blank IDs — `recordWatch` doesn't gate on those,
|
||||
// and a blank in the set would `extractVideoId(url)=""` match
|
||||
// EVERY malformed-URL item and silently hide them all.
|
||||
val watchedIds = remember(watches) {
|
||||
watches.map { it.videoId }.filter { it.isNotBlank() }.toSet()
|
||||
}
|
||||
|
||||
val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState()
|
||||
val filteredItems = remember(feed.items, hideWatched, watchedIds, hideShorts) {
|
||||
val watchFiltered = if (!hideWatched) feed.items
|
||||
else feed.items.filterNot { extractVideoId(it.url) in watchedIds }
|
||||
com.sulkta.straw.util.applyContentFilters(watchFiltered, hideShorts = hideShorts)
|
||||
}
|
||||
// Reset pagination when the underlying list changes so the user
|
||||
// doesn't end up looking at "no more items" after a refresh.
|
||||
LaunchedEffect(filteredItems) {
|
||||
if (visibleCount > filteredItems.size.coerceAtLeast(PAGE_SIZE)) {
|
||||
visibleCount = PAGE_SIZE
|
||||
}
|
||||
}
|
||||
// remember the page-slice so we don't allocate a new ArrayList on
|
||||
// every recomposition (scroll hitch).
|
||||
val displayed = remember(filteredItems, visibleCount) {
|
||||
filteredItems.take(visibleCount)
|
||||
}
|
||||
val hasMore = filteredItems.size > visibleCount
|
||||
|
||||
Column {
|
||||
if (subs.isEmpty()) {
|
||||
Text(
|
||||
|
|
@ -267,6 +353,13 @@ private fun SubsPane(
|
|||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
FilterChip(
|
||||
selected = hideWatched,
|
||||
onClick = { hideWatched = !hideWatched },
|
||||
label = { Text("Hide watched") },
|
||||
colors = FilterChipDefaults.filterChipColors(),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
TextButton(onClick = { feedVm.refresh() }) {
|
||||
Text(if (feed.loading) "..." else "Refresh")
|
||||
}
|
||||
|
|
@ -280,7 +373,7 @@ private fun SubsPane(
|
|||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Show a slim error banner above cached items even if we have data —
|
||||
// audit HIGH-7: previously a 401/429 looked identical to a successful
|
||||
// previously a 401/429 looked identical to a successful
|
||||
// refresh because the error chip was hidden whenever items != empty.
|
||||
if (feed.error != null && feed.items.isNotEmpty()) {
|
||||
Text(
|
||||
|
|
@ -309,34 +402,127 @@ private fun SubsPane(
|
|||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
feed.items.isNotEmpty() && filteredItems.isEmpty() -> {
|
||||
Text(
|
||||
"All caught up — nothing unwatched.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LazyColumn {
|
||||
items(feed.items) { item ->
|
||||
FeedRow(item) { onOpenVideo(item.url, item.title) }
|
||||
val listState = rememberLazyListState()
|
||||
// Bump visibleCount when the user scrolls within 5 items
|
||||
// of the current bottom. snapshotFlow + derivedStateOf
|
||||
// keeps this off the per-frame recompose path.
|
||||
val nearBottom by remember {
|
||||
derivedStateOf {
|
||||
val info = listState.layoutInfo
|
||||
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||
lastVisible >= info.totalItemsCount - 5
|
||||
}
|
||||
}
|
||||
// Key on listState only — the previous key set
|
||||
// (displayed.size, hasMore) was mutated BY this effect,
|
||||
// which cancelled the snapshotFlow collector mid-stream
|
||||
// and produced the "scrolled to bottom, nothing loads"
|
||||
// bug from the audit.
|
||||
//
|
||||
// hasMore and filteredItems are read inside the
|
||||
// snapshotFlow producer (not closed over from outside)
|
||||
// so Compose re-reads them on each frame instead of
|
||||
// capturing the stale value at lambda-creation time.
|
||||
val filteredCount = filteredItems.size
|
||||
LaunchedEffect(listState, filteredCount) {
|
||||
snapshotFlow {
|
||||
nearBottom && visibleCount < filteredCount
|
||||
}.collect { shouldGrow ->
|
||||
if (shouldGrow) {
|
||||
visibleCount = (visibleCount + PAGE_SIZE)
|
||||
.coerceAtMost(filteredCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = rememberBottomContentPadding(),
|
||||
) {
|
||||
items(
|
||||
items = displayed,
|
||||
key = { it.url },
|
||||
) { item ->
|
||||
FeedRow(
|
||||
item = item,
|
||||
onClick = { onOpenVideo(item.url, item.title) },
|
||||
onLongClick = {
|
||||
actionTarget = VideoActionTarget(
|
||||
streamUrl = item.url,
|
||||
title = item.title,
|
||||
uploader = item.uploader,
|
||||
thumbnail = item.thumbnail,
|
||||
)
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
if (hasMore) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
"Loading more...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val PAGE_SIZE = 20
|
||||
|
||||
/**
|
||||
* Extract the YouTube video ID from a watch URL so we can cross-check
|
||||
* against History.watches (which stores videoId, not full URL). Handles
|
||||
* the common forms: youtube.com/watch?v=XXXXXXXXXXX and youtu.be/X...
|
||||
* Returns empty string when nothing matches — callers compare against
|
||||
* watchedIds, so an empty string just won't filter anything out.
|
||||
*/
|
||||
private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&#].*)?$")
|
||||
private fun extractVideoId(url: String): String =
|
||||
VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1).orEmpty()
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
|
||||
private fun FeedRow(
|
||||
item: StreamItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.thumbnail,
|
||||
contentDescription = null,
|
||||
VideoThumbnail(
|
||||
thumbnail = item.thumbnail,
|
||||
videoUrl = item.url,
|
||||
durationSeconds = item.durationSeconds,
|
||||
modifier = Modifier
|
||||
.width(140.dp)
|
||||
.height(80.dp)
|
||||
.clip(RoundedCornerShape(6.dp)),
|
||||
.height(80.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
|
|
@ -355,6 +541,10 @@ private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
|
|||
append(" · ")
|
||||
append(formatViews(item.viewCount))
|
||||
}
|
||||
if (item.uploadDateRelative.isNotBlank()) {
|
||||
append(" · ")
|
||||
append(item.uploadDateRelative)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
|
|
@ -376,37 +566,71 @@ private fun SubChip(
|
|||
.clickable { onOpenChannel(ch.url, ch.name) },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ch.avatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(56.dp).clip(CircleShape),
|
||||
)
|
||||
if (ch.avatar.isNullOrBlank()) {
|
||||
// Lettered fallback — strawcore can return a null avatar
|
||||
// when the channel header layout doesn't include one (more
|
||||
// common on smaller channels). Feed-fetch backfills this
|
||||
// asynchronously via Subscriptions.updateAvatar, but until
|
||||
// it arrives we still want SOMETHING visible.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = ch.name.firstOrNull()?.uppercase().orEmpty(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = ch.avatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(56.dp).clip(CircleShape),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// Single line + ellipsis instead of maxLines=2. The 80dp chip
|
||||
// width breaks the prior 2-line wrap mid-word ("NoCopyrightS
|
||||
// / ounds", "DEFCONConfe / rence") — uglier than a clean
|
||||
// "NoCopyrigh…". Centered text alignment so the ellipsis
|
||||
// sits over the chip's icon column.
|
||||
Text(
|
||||
text = ch.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun RecentRow(item: WatchHistoryItem, onClick: () -> Unit) {
|
||||
private fun RecentRow(
|
||||
item: WatchHistoryItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.thumbnail,
|
||||
contentDescription = null,
|
||||
VideoThumbnail(
|
||||
thumbnail = item.thumbnail,
|
||||
videoUrl = item.url,
|
||||
durationSeconds = 0L,
|
||||
modifier = Modifier
|
||||
.width(120.dp)
|
||||
.height(68.dp)
|
||||
.clip(RoundedCornerShape(6.dp)),
|
||||
.height(68.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
|
|
|
|||
90
strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Straw palette pulled directly from sulkta.com's stylesheet:
|
||||
* #4ade80 primary green (Tailwind green-400, most-used on the site)
|
||||
* #166534 deep green (green-800, headings + emphasis)
|
||||
* #22c55e mid green (green-500, links + buttons)
|
||||
* #86efac light green container (green-300)
|
||||
* #e8f5e8 pale green tint
|
||||
* #d97706 amber accent (sulkta.com calls this out for chips)
|
||||
* #374137 olive-gray secondary
|
||||
* #0a0a0a near-black text on light
|
||||
* #111411 near-black with green tint for dark surface
|
||||
*
|
||||
* Mapped into Material 3's primary / secondary / tertiary tonal roles
|
||||
* so all the derived M3 surfaces (containers, outlines, etc.) follow.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Light theme — primary is sulkta.com's deep green (#166534), strong
|
||||
// enough for white text and matches the site's heading emphasis.
|
||||
private val LPrimary = Color(0xFF166534)
|
||||
private val LOnPrimary = Color(0xFFFFFFFF)
|
||||
private val LPrimaryContainer = Color(0xFF86EFAC)
|
||||
private val LOnPrimaryContainer = Color(0xFF0A0A0A)
|
||||
private val LSecondary = Color(0xFF374137)
|
||||
private val LOnSecondary = Color(0xFFFFFFFF)
|
||||
private val LSecondaryContainer = Color(0xFFE8F5E8)
|
||||
private val LOnSecondaryContainer = Color(0xFF0A0A0A)
|
||||
private val LTertiary = Color(0xFFD97706)
|
||||
private val LOnTertiary = Color(0xFFFFFFFF)
|
||||
|
||||
// Dark theme — primary is sulkta.com's bright lime (#4ade80) since dark
|
||||
// backgrounds need a brighter accent for readability. PrimaryContainer
|
||||
// is the deep green so emphasis stays consistent across themes.
|
||||
private val DPrimary = Color(0xFF4ADE80)
|
||||
private val DOnPrimary = Color(0xFF0A0A0A)
|
||||
private val DPrimaryContainer = Color(0xFF166534)
|
||||
private val DOnPrimaryContainer = Color(0xFF86EFAC)
|
||||
private val DSecondary = Color(0xFF9AB89A)
|
||||
private val DOnSecondary = Color(0xFF111411)
|
||||
private val DSecondaryContainer = Color(0xFF374137)
|
||||
private val DOnSecondaryContainer = Color(0xFFE8F5E8)
|
||||
private val DTertiary = Color(0xFFD97706)
|
||||
private val DOnTertiary = Color(0xFF0A0A0A)
|
||||
|
||||
fun strawLightColors(): ColorScheme = lightColorScheme(
|
||||
primary = LPrimary,
|
||||
onPrimary = LOnPrimary,
|
||||
primaryContainer = LPrimaryContainer,
|
||||
onPrimaryContainer = LOnPrimaryContainer,
|
||||
secondary = LSecondary,
|
||||
onSecondary = LOnSecondary,
|
||||
secondaryContainer = LSecondaryContainer,
|
||||
onSecondaryContainer = LOnSecondaryContainer,
|
||||
tertiary = LTertiary,
|
||||
onTertiary = LOnTertiary,
|
||||
)
|
||||
|
||||
fun strawDarkColors(): ColorScheme = darkColorScheme(
|
||||
primary = DPrimary,
|
||||
onPrimary = DOnPrimary,
|
||||
primaryContainer = DPrimaryContainer,
|
||||
onPrimaryContainer = DOnPrimaryContainer,
|
||||
secondary = DSecondary,
|
||||
onSecondary = DOnSecondary,
|
||||
secondaryContainer = DSecondaryContainer,
|
||||
onSecondaryContainer = DOnSecondaryContainer,
|
||||
tertiary = DTertiary,
|
||||
onTertiary = DOnTertiary,
|
||||
)
|
||||
|
||||
// Semi-transparent overlays for chrome (overlay buttons, the SB badge,
|
||||
// the inline-player fullscreen pill) and for the dimmed area behind the
|
||||
// minibar thumbnail. Kept here so a theme tweak touches one place.
|
||||
val OverlayChromeColor = Color(0xCC222222)
|
||||
val OverlayDimColor = Color(0xCC000000)
|
||||
|
||||
// Watch-progress bar painted across the bottom of a video thumbnail when
|
||||
// the user has a saved scrub-point. Solid red foreground over a slightly-
|
||||
// dim track. Matches YT / NewPipe conventions so it reads instantly.
|
||||
val ProgressBarFillColor = Color(0xFFE53935)
|
||||
val ProgressBarTrackColor = Color(0x66000000)
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Subs-feed enrichment cache. RSS gives us title/url/thumbnail/date
|
||||
* fast but no view count or duration. After a feed refresh paints
|
||||
* from RSS, SubscriptionFeedViewModel fans out lightweight
|
||||
* uniffi.strawcore.enrichFeedItem() calls for the top visible items
|
||||
* and stashes the results here. mergeFromCache overlays the
|
||||
* enrichment onto each StreamItem at render time so the row shows
|
||||
* 'N views · X duration' once available.
|
||||
*
|
||||
* Storage: SharedPreferences-lite, single JSON blob keyed by videoId.
|
||||
* TTL bound to Settings.cacheTtl so enrichments age out alongside the
|
||||
* rest of the cache. Hard cap at MAX_ENRICHMENTS to bound disk +
|
||||
* memory.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.updateAndGet
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class Enrichment(
|
||||
val viewCount: Long,
|
||||
val durationSeconds: Long,
|
||||
val fetchedAt: Long,
|
||||
)
|
||||
|
||||
private const val PREFS = "straw_feed_enrichment"
|
||||
private const val KEY = "enrichments_v1"
|
||||
|
||||
/**
|
||||
* Hard ceiling — keeps the JSON blob below ~250 KB even at the cap
|
||||
* (50 bytes/entry × 5000 = 250 KB). The user-facing cap doesn't tie
|
||||
* to this; enrichment is "cache" not "user data."
|
||||
*/
|
||||
private const val MAX_ENRICHMENTS = 5_000
|
||||
|
||||
class EnrichmentStore(context: Context) {
|
||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val _entries = MutableStateFlow(load())
|
||||
val entries: StateFlow<Map<String, Enrichment>> = _entries.asStateFlow()
|
||||
|
||||
/**
|
||||
* Return a fresh enrichment for this videoId, or null when missing
|
||||
* or aged out per Settings.cacheTtl. Forever-TTL never expires.
|
||||
*/
|
||||
fun get(videoId: String): Enrichment? {
|
||||
if (videoId.isBlank()) return null
|
||||
val e = _entries.value[videoId] ?: return null
|
||||
val ttl = Settings.get().cacheTtl.value
|
||||
if (ttl.isForever) return e
|
||||
val cutoff = System.currentTimeMillis() - ttl.ms
|
||||
return if (e.fetchedAt >= cutoff) e else null
|
||||
}
|
||||
|
||||
fun put(videoId: String, viewCount: Long, durationSeconds: Long) {
|
||||
if (videoId.isBlank()) return
|
||||
// Don't write all-zero entries — that's failure not data, and
|
||||
// would waste a slot the cap could spend on a real hit.
|
||||
if (viewCount <= 0L && durationSeconds <= 0L) return
|
||||
val entry = Enrichment(
|
||||
viewCount = viewCount,
|
||||
durationSeconds = durationSeconds,
|
||||
fetchedAt = System.currentTimeMillis(),
|
||||
)
|
||||
val before = _entries.value
|
||||
val next = _entries.updateAndGet { current ->
|
||||
// short-circuit when the cached
|
||||
// value is already the same view+duration — re-enriching
|
||||
// within TTL otherwise allocates a new Map every call
|
||||
// and the `before !== next` guard never triggers, so a
|
||||
// refresh-after-refresh hammers the SP file.
|
||||
val existing = current[videoId]
|
||||
if (existing != null &&
|
||||
existing.viewCount == entry.viewCount &&
|
||||
existing.durationSeconds == entry.durationSeconds) {
|
||||
return@updateAndGet current
|
||||
}
|
||||
val withEntry = current + (videoId to entry)
|
||||
if (withEntry.size > MAX_ENRICHMENTS) {
|
||||
withEntry.entries
|
||||
.sortedByDescending { it.value.fetchedAt }
|
||||
.take(MAX_ENRICHMENTS)
|
||||
.associate { it.key to it.value }
|
||||
} else {
|
||||
withEntry
|
||||
}
|
||||
}
|
||||
if (next !== before) {
|
||||
sp.edit().putString(KEY, json.encodeToString(next)).apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_entries.updateAndGet { emptyMap() }
|
||||
sp.edit().putString(KEY, json.encodeToString(emptyMap<String, Enrichment>())).apply()
|
||||
}
|
||||
|
||||
private fun load(): Map<String, Enrichment> = runCatching {
|
||||
val s = sp.getString(KEY, null) ?: return emptyMap()
|
||||
val loaded = json.decodeFromString<Map<String, Enrichment>>(s)
|
||||
// prune TTL-expired entries on load
|
||||
// so the store doesn't accumulate dead weight up to
|
||||
// MAX_ENRICHMENTS over time. `Forever` TTL skips the prune.
|
||||
val ttl = Settings.get().cacheTtl.value
|
||||
if (ttl.isForever) return loaded
|
||||
val cutoff = System.currentTimeMillis() - ttl.ms
|
||||
loaded.filterValues { it.fetchedAt >= cutoff }
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
object FeedEnrichment {
|
||||
@Volatile private var instance: EnrichmentStore? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
if (instance == null) {
|
||||
synchronized(this) {
|
||||
if (instance == null) instance = EnrichmentStore(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get(): EnrichmentStore = instance
|
||||
?: error("EnrichmentStore not initialized — call FeedEnrichment.init(context)")
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Persistent per-channel cache for the subscription feed. Survives
|
||||
* process death, so opening Subs after a cold start shows the last
|
||||
* successful fetch immediately instead of waiting 5+ seconds for 30
|
||||
* channel browses to resolve.
|
||||
*
|
||||
* Storage: SharedPreferences with a single JSON blob. Total payload is
|
||||
* small (30 subs * 30 items * ~250 bytes = ~225 KB), well within SP's
|
||||
* comfortable size and well below the multi-MB threshold where you'd
|
||||
* want to graduate to Room or a file.
|
||||
*
|
||||
* Concurrency: writes from the feed VM are debounced via the single
|
||||
* `persist` call inside fetchChannelInto's success path. Reads happen
|
||||
* on VM init and are synchronous.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.sulkta.straw.feature.search.StreamItem
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class FeedCacheEntry(
|
||||
val fetchedAt: Long,
|
||||
val items: List<StreamItem>,
|
||||
)
|
||||
|
||||
private const val PREFS = "straw_feed_cache"
|
||||
private const val KEY = "cache_v1"
|
||||
|
||||
class FeedCacheStore(context: Context) {
|
||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Snapshot of the disk cache, filtered by the user-configured TTL.
|
||||
* Returns empty map if nothing saved or everything expired.
|
||||
* Settings.cacheTtl.isForever short-circuits the filter; finite TTLs
|
||||
* drop entries whose fetchedAt is older than (now - ttl).
|
||||
*/
|
||||
fun load(): Map<String, FeedCacheEntry> = runCatching {
|
||||
val s = sp.getString(KEY, null) ?: return emptyMap()
|
||||
val raw = json.decodeFromString<Map<String, FeedCacheEntry>>(s)
|
||||
val ttl = Settings.get().cacheTtl.value
|
||||
if (ttl.isForever) return raw
|
||||
val cutoff = System.currentTimeMillis() - ttl.ms
|
||||
raw.filterValues { it.fetchedAt >= cutoff }
|
||||
}.getOrDefault(emptyMap())
|
||||
|
||||
/** Atomic write. Caller is responsible for diffing if needed. */
|
||||
fun save(map: Map<String, FeedCacheEntry>) {
|
||||
val s = json.encodeToString(map)
|
||||
sp.edit().putString(KEY, s).apply()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
sp.edit().remove(KEY).apply()
|
||||
}
|
||||
}
|
||||
|
||||
object FeedCache {
|
||||
@Volatile private var appContext: Context? = null
|
||||
@Volatile private var instance: FeedCacheStore? = null
|
||||
|
||||
/**
|
||||
* Lazy init: stash the applicationContext only. The actual Store
|
||||
* (and the ~225 KB JSON decode that happens at construction) is
|
||||
* deferred until the first `get()` call. Lets Application.onCreate
|
||||
* return quickly while every caller still gets a valid Store —
|
||||
* Callers should access from a coroutine
|
||||
* (IO dispatcher) where the lazy construction cost is acceptable.
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
fun get(): FeedCacheStore {
|
||||
instance?.let { return it }
|
||||
synchronized(this) {
|
||||
instance?.let { return it }
|
||||
val ctx = appContext
|
||||
?: error("FeedCacheStore not initialized — call FeedCache.init(context)")
|
||||
val built = FeedCacheStore(ctx)
|
||||
instance = built
|
||||
return built
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@
|
|||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* SharedPreferences-backed recent watches + recent search store. Day-3.
|
||||
* Day-4 graduates to Room when there's a real query pattern (date ranges,
|
||||
* full-text search, etc.) that SharedPreferences can't serve.
|
||||
* Recent watches + recent searches backed by SharedPreferences JSON
|
||||
* blobs. Capped to maxWatches() / maxSearches(). Graduates to Room when
|
||||
* a real query pattern (date ranges, full-text search) shows up.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.data
|
||||
|
|
@ -31,12 +31,29 @@ data class WatchHistoryItem(
|
|||
private const val PREFS = "straw_history"
|
||||
private const val KEY_WATCHES = "watches_v1"
|
||||
private const val KEY_SEARCHES = "searches_v1"
|
||||
private const val MAX_WATCHES = 50
|
||||
private const val MAX_SEARCHES = 20
|
||||
|
||||
/**
|
||||
* Earlier hard limits. Still used as the absolute upper bound when
|
||||
* Settings.historyWatchesCap is CacheCap.Unlimited — we don't want to
|
||||
* allow truly-uncapped growth that could OOM SP on a hostile import.
|
||||
* Any user-picked cap above this is silently floored to MAX_*_HARD.
|
||||
*/
|
||||
private const val MAX_WATCHES_HARD = 100_000
|
||||
private const val MAX_SEARCHES_HARD = 100_000
|
||||
|
||||
class HistoryStore(context: Context) {
|
||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun maxWatches(): Int {
|
||||
val cap = Settings.get().historyWatchesCap.value.value
|
||||
return cap.coerceAtMost(MAX_WATCHES_HARD)
|
||||
}
|
||||
|
||||
private fun maxSearches(): Int {
|
||||
val cap = Settings.get().historySearchesCap.value.value
|
||||
return cap.coerceAtMost(MAX_SEARCHES_HARD)
|
||||
}
|
||||
|
||||
private val _watches = MutableStateFlow(loadWatches())
|
||||
val watches: StateFlow<List<WatchHistoryItem>> = _watches.asStateFlow()
|
||||
|
|
@ -46,34 +63,128 @@ class HistoryStore(context: Context) {
|
|||
|
||||
fun recordWatch(item: WatchHistoryItem) {
|
||||
val now = item.copy(watchedAt = System.currentTimeMillis())
|
||||
// Atomic read-modify-write via StateFlow.updateAndGet — fixes
|
||||
// AUD-HIGH race where two concurrent recordWatch calls would
|
||||
// each read the old list and one would clobber the other.
|
||||
// Atomic read-modify-write — two concurrent recordWatch calls
|
||||
// both reading the same `current` and one clobbering the other
|
||||
// is exactly the bug updateAndGet avoids.
|
||||
val next = _watches.updateAndGet { current ->
|
||||
val without = current.filterNot { it.videoId == item.videoId }
|
||||
(listOf(now) + without).take(MAX_WATCHES)
|
||||
(listOf(now) + without).take(maxWatches())
|
||||
}
|
||||
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk import. Callers (currently SettingsImport) feed
|
||||
* 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<WatchHistoryItem>): Int {
|
||||
if (items.isEmpty()) return 0
|
||||
val before = _watches.value
|
||||
val counter = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
val next = _watches.updateAndGet { current ->
|
||||
// Reset the counter inside the CAS lambda so a retry
|
||||
// doesn't accumulate across attempts — same shape as
|
||||
// SubscriptionsStore.addAll's round-3 fix.
|
||||
counter.set(0)
|
||||
val seen = HashSet<String>(current.size + items.size)
|
||||
current.forEach { seen.add(it.videoId) }
|
||||
// Build the import list newest-first. Capped at
|
||||
// maxWatches() on its own so we don't over-allocate
|
||||
// even on a 50k-row hostile export.
|
||||
val fresh = ArrayList<WatchHistoryItem>(maxWatches())
|
||||
val it = items.listIterator(items.size)
|
||||
while (it.hasPrevious() && fresh.size < maxWatches()) {
|
||||
val item = it.previous()
|
||||
if (item.videoId.isBlank()) continue
|
||||
if (!seen.add(item.videoId)) continue
|
||||
fresh.add(item)
|
||||
counter.incrementAndGet()
|
||||
}
|
||||
if (fresh.isEmpty()) return@updateAndGet current
|
||||
// Combine + cap. take() truncates older `current` entries
|
||||
// when we'd exceed maxWatches(), so imports always land.
|
||||
(fresh + current).take(maxWatches())
|
||||
}
|
||||
if (next !== before) {
|
||||
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
|
||||
}
|
||||
return counter.get()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk import for search history. Same pattern as
|
||||
* recordAllWatches — single SP write regardless of input size.
|
||||
* SettingsImport.importHistory previously called recordSearch per
|
||||
* row, producing N SP writes on a potentially-100k-row import.
|
||||
*/
|
||||
/**
|
||||
* Returns the number of fresh queries actually folded into the
|
||||
* store — same counter pattern as recordAllWatches.
|
||||
*/
|
||||
fun recordAllSearches(queries: List<String>): Int {
|
||||
if (queries.isEmpty()) return 0
|
||||
val before = _searches.value
|
||||
val counter = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
val next = _searches.updateAndGet { current ->
|
||||
counter.set(0)
|
||||
val seen = HashSet<String>(current.size + queries.size)
|
||||
current.forEach { seen.add(it.lowercase()) }
|
||||
val fresh = ArrayList<String>(maxSearches())
|
||||
val it = queries.listIterator(queries.size)
|
||||
while (it.hasPrevious() && fresh.size < maxSearches()) {
|
||||
val q = it.previous().trim()
|
||||
if (q.isEmpty()) continue
|
||||
if (!seen.add(q.lowercase())) continue
|
||||
fresh.add(q)
|
||||
counter.incrementAndGet()
|
||||
}
|
||||
if (fresh.isEmpty()) return@updateAndGet current
|
||||
(fresh + current).take(maxSearches())
|
||||
}
|
||||
if (next !== before) {
|
||||
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
|
||||
}
|
||||
return counter.get()
|
||||
}
|
||||
|
||||
fun recordSearch(query: String) {
|
||||
val q = query.trim()
|
||||
if (q.isEmpty()) return
|
||||
val next = _searches.updateAndGet { current ->
|
||||
val without = current.filterNot { it.equals(q, ignoreCase = true) }
|
||||
(listOf(q) + without).take(MAX_SEARCHES)
|
||||
(listOf(q) + without).take(maxSearches())
|
||||
}
|
||||
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
|
||||
}
|
||||
|
||||
fun clearWatches() {
|
||||
_watches.value = emptyList()
|
||||
sp.edit().remove(KEY_WATCHES).apply()
|
||||
_watches.updateAndGet { emptyList() }
|
||||
sp.edit().putString(KEY_WATCHES, json.encodeToString(emptyList<WatchHistoryItem>())).apply()
|
||||
}
|
||||
|
||||
fun clearSearches() {
|
||||
_searches.value = emptyList()
|
||||
sp.edit().remove(KEY_SEARCHES).apply()
|
||||
_searches.updateAndGet { emptyList() }
|
||||
sp.edit().putString(KEY_SEARCHES, json.encodeToString(emptyList<String>())).apply()
|
||||
}
|
||||
|
||||
private fun loadWatches(): List<WatchHistoryItem> = runCatching {
|
||||
|
|
|
|||
154
strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* SharedPreferences-lite local playlists. User creates a playlist
|
||||
* ("study music", "boss fight rage"), saves videos to it from
|
||||
* VideoDetailScreen, and replays them later from the drawer. Same
|
||||
* persistence pattern as SubscriptionsStore — JSON blob in
|
||||
* SharedPreferences, atomic updates via updateAndGet so concurrent
|
||||
* "save to playlist" taps don't lose entries.
|
||||
*
|
||||
* No queue-autoplay yet — tapping a video in a playlist navigates to
|
||||
* VideoDetail like normal. Queue handoff would be its own task.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.updateAndGet
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class PlaylistItem(
|
||||
val streamUrl: String,
|
||||
val title: String,
|
||||
val thumbnail: String? = null,
|
||||
val uploader: String = "",
|
||||
val addedAt: Long = 0L,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Playlist(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val createdAt: Long,
|
||||
val items: List<PlaylistItem> = emptyList(),
|
||||
)
|
||||
|
||||
private const val PREFS = "straw_playlists"
|
||||
private const val KEY = "playlists_v1"
|
||||
|
||||
class PlaylistsStore(context: Context) {
|
||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val _playlists = MutableStateFlow(load())
|
||||
val playlists: StateFlow<List<Playlist>> = _playlists.asStateFlow()
|
||||
|
||||
fun create(name: String): Playlist {
|
||||
val pl = Playlist(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = name.trim().ifBlank { "Untitled" },
|
||||
createdAt = System.currentTimeMillis(),
|
||||
)
|
||||
val next = _playlists.updateAndGet { it + pl }
|
||||
persist(next)
|
||||
return pl
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-import a playlist with all its items in a single CAS +
|
||||
* single SP write. SettingsImport's old shape called create() +
|
||||
* addItem() in a loop — both write SP, and addItem walks every
|
||||
* playlist linearly per insert. A 100-playlist × 100-items
|
||||
* NewPipe export was ~10,001 SP commits + ~10M comparisons.
|
||||
*/
|
||||
fun importPlaylist(name: String, items: List<PlaylistItem>): Playlist {
|
||||
val stampNow = System.currentTimeMillis()
|
||||
// Dedup within the import + stamp addedAt once.
|
||||
val seen = HashSet<String>()
|
||||
val deduped = ArrayList<PlaylistItem>(items.size)
|
||||
for (it in items) {
|
||||
if (it.streamUrl.isBlank()) continue
|
||||
if (!seen.add(it.streamUrl)) continue
|
||||
deduped.add(it.copy(addedAt = if (it.addedAt == 0L) stampNow else it.addedAt))
|
||||
}
|
||||
val pl = Playlist(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = name.trim().ifBlank { "Untitled" },
|
||||
createdAt = stampNow,
|
||||
items = deduped,
|
||||
)
|
||||
val next = _playlists.updateAndGet { it + pl }
|
||||
persist(next)
|
||||
return pl
|
||||
}
|
||||
|
||||
fun delete(id: String) {
|
||||
val next = _playlists.updateAndGet { cur -> cur.filterNot { it.id == id } }
|
||||
persist(next)
|
||||
}
|
||||
|
||||
fun rename(id: String, newName: String) {
|
||||
val trimmed = newName.trim().ifBlank { return }
|
||||
val next = _playlists.updateAndGet { cur ->
|
||||
cur.map { if (it.id == id) it.copy(name = trimmed) else it }
|
||||
}
|
||||
persist(next)
|
||||
}
|
||||
|
||||
fun addItem(playlistId: String, item: PlaylistItem) {
|
||||
val stamped = item.copy(addedAt = System.currentTimeMillis())
|
||||
val next = _playlists.updateAndGet { cur ->
|
||||
cur.map { pl ->
|
||||
if (pl.id != playlistId) pl
|
||||
else if (pl.items.any { it.streamUrl == stamped.streamUrl }) pl
|
||||
else pl.copy(items = pl.items + stamped)
|
||||
}
|
||||
}
|
||||
persist(next)
|
||||
}
|
||||
|
||||
fun removeItem(playlistId: String, streamUrl: String) {
|
||||
val next = _playlists.updateAndGet { cur ->
|
||||
cur.map { pl ->
|
||||
if (pl.id != playlistId) pl
|
||||
else pl.copy(items = pl.items.filterNot { it.streamUrl == streamUrl })
|
||||
}
|
||||
}
|
||||
persist(next)
|
||||
}
|
||||
|
||||
fun get(id: String): Playlist? = _playlists.value.firstOrNull { it.id == id }
|
||||
|
||||
private fun persist(list: List<Playlist>) {
|
||||
sp.edit().putString(KEY, json.encodeToString(list)).apply()
|
||||
}
|
||||
|
||||
private fun load(): List<Playlist> = runCatching {
|
||||
val s = sp.getString(KEY, null) ?: return emptyList()
|
||||
json.decodeFromString<List<Playlist>>(s)
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
object Playlists {
|
||||
@Volatile private var instance: PlaylistsStore? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
if (instance == null) {
|
||||
synchronized(this) {
|
||||
if (instance == null) instance = PlaylistsStore(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get(): PlaylistsStore = instance
|
||||
?: error("PlaylistsStore not initialized — call Playlists.init(context)")
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Per-video scrub-point store. App update / process death / device
|
||||
* reboot — all three would otherwise lose the user's place in a long
|
||||
* video. We write position every ~5s while playing + on every pause +
|
||||
* on player teardown, keyed by videoId so resume works across stream
|
||||
* URL rotations (googlevideo URLs rotate per session).
|
||||
*
|
||||
* SharedPreferences-lite, single JSON blob, capped at maxResumes() with
|
||||
* oldest-eviction. Same shape as HistoryStore — graduates to Room if a
|
||||
* real query pattern shows up.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.sulkta.straw.StrawApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.updateAndGet
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class ResumePosition(
|
||||
val positionMs: Long,
|
||||
val durationMs: Long,
|
||||
val lastWatchedAt: Long,
|
||||
)
|
||||
|
||||
private const val PREFS = "straw_resume_positions"
|
||||
private const val KEY_POSITIONS = "positions_v1"
|
||||
|
||||
/**
|
||||
* Earlier hard cap. Now a ceiling rather than a fixed value: the
|
||||
* user-picked cap from Settings.resumePositionsCap is silently floored
|
||||
* to this so even "Unlimited" doesn't OOM SP. Bigger ceiling here
|
||||
* than HistoryStore because resume entries are tiny (~50 bytes each)
|
||||
* vs WatchHistoryItem's ~250 bytes.
|
||||
*/
|
||||
private const val MAX_RESUMES_HARD = 100_000
|
||||
|
||||
/**
|
||||
* Skip writes for trivial positions — auto-resuming from 0:03 is more
|
||||
* annoying than starting fresh. Mirrors YouTube's "near the start"
|
||||
* threshold.
|
||||
*/
|
||||
private const val MIN_POSITION_MS = 5_000L
|
||||
|
||||
/**
|
||||
* When position is within END_THRESHOLD of duration, treat the video as
|
||||
* "done" and clear the entry instead of recording. Otherwise a finished
|
||||
* watch would auto-resume to the credits next time.
|
||||
*/
|
||||
private const val END_THRESHOLD_MS = 5_000L
|
||||
|
||||
class ResumePositionsStore(context: Context) {
|
||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val _positions = MutableStateFlow(load())
|
||||
val positions: StateFlow<Map<String, ResumePosition>> = _positions.asStateFlow()
|
||||
|
||||
private fun maxResumes(): Int {
|
||||
val cap = Settings.get().resumePositionsCap.value.value
|
||||
return cap.coerceAtMost(MAX_RESUMES_HARD)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record (or update) the scrub-point for a video. Skipped silently
|
||||
* when:
|
||||
* - videoId is blank
|
||||
* - durationMs <= 0 (live stream / unknown)
|
||||
* - positionMs is below MIN_POSITION_MS (just started)
|
||||
*
|
||||
* When positionMs is within END_THRESHOLD_MS of the end the entry is
|
||||
* REMOVED so a finished video doesn't auto-resume to its credits.
|
||||
*/
|
||||
fun record(videoId: String, positionMs: Long, durationMs: Long) {
|
||||
if (videoId.isBlank()) return
|
||||
if (durationMs <= 0L) return
|
||||
if (positionMs < MIN_POSITION_MS) return
|
||||
if (positionMs >= durationMs - END_THRESHOLD_MS) {
|
||||
clearOne(videoId)
|
||||
return
|
||||
}
|
||||
val entry = ResumePosition(
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs,
|
||||
lastWatchedAt = System.currentTimeMillis(),
|
||||
)
|
||||
val before = _positions.value
|
||||
val next = _positions.updateAndGet { current ->
|
||||
// short-circuit value-equality
|
||||
// a 5s poll tick that finds the same (position, duration,
|
||||
// wall-time) for an existing entry returns `current`
|
||||
// unchanged so the outer `next !== before` guard
|
||||
// actually short-circuits the SP write.
|
||||
//
|
||||
// lastWatchedAt updates every tick by definition, but
|
||||
// ResumePosition equality on position+duration alone is
|
||||
// ALL we care about for "did anything meaningful change."
|
||||
// We re-stamp lastWatchedAt only when the player position
|
||||
// actually advances.
|
||||
val existing = current[videoId]
|
||||
if (existing != null &&
|
||||
existing.positionMs == entry.positionMs &&
|
||||
existing.durationMs == entry.durationMs) {
|
||||
return@updateAndGet current
|
||||
}
|
||||
val withEntry = current + (videoId to entry)
|
||||
// Skip sort+associate when we're under the cap (the
|
||||
// common case at default 500). Sort is O(n log n);
|
||||
// associate allocates another map.
|
||||
if (withEntry.size > maxResumes()) {
|
||||
// Drop oldest by lastWatchedAt — newcomers always land
|
||||
// because the entry we just added is by definition the
|
||||
// freshest. take(maxResumes()) of the sorted-desc list.
|
||||
withEntry.entries
|
||||
.sortedByDescending { it.value.lastWatchedAt }
|
||||
.take(maxResumes())
|
||||
.associate { it.key to it.value }
|
||||
} else {
|
||||
withEntry
|
||||
}
|
||||
}
|
||||
if (next !== before) {
|
||||
// JSON encode + SP write off Main — encoding 100k entries
|
||||
// would be ~50-100 ms on a low-end device, and the 5s
|
||||
// captureResumePosition poll runs on Main.
|
||||
StrawApp.globalScope.launch(Dispatchers.IO) {
|
||||
sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns null when the video has no recorded position. */
|
||||
fun get(videoId: String): ResumePosition? {
|
||||
if (videoId.isBlank()) return null
|
||||
return _positions.value[videoId]
|
||||
}
|
||||
|
||||
fun clearOne(videoId: String) {
|
||||
if (videoId.isBlank()) return
|
||||
val before = _positions.value
|
||||
val next = _positions.updateAndGet { current ->
|
||||
if (videoId !in current) current else current - videoId
|
||||
}
|
||||
if (next !== before) {
|
||||
StrawApp.globalScope.launch(Dispatchers.IO) {
|
||||
sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
val before = _positions.value
|
||||
_positions.updateAndGet { emptyMap() }
|
||||
if (before.isNotEmpty()) {
|
||||
StrawApp.globalScope.launch(Dispatchers.IO) {
|
||||
sp.edit().putString(KEY_POSITIONS, json.encodeToString(emptyMap<String, ResumePosition>())).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(): Map<String, ResumePosition> = runCatching {
|
||||
val s = sp.getString(KEY_POSITIONS, null) ?: return emptyMap()
|
||||
json.decodeFromString<Map<String, ResumePosition>>(s)
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
/** App-wide singleton; created in StrawApp.onCreate. */
|
||||
object Resume {
|
||||
@Volatile private var instance: ResumePositionsStore? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
if (instance == null) {
|
||||
synchronized(this) {
|
||||
if (instance == null) instance = ResumePositionsStore(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get(): ResumePositionsStore = instance
|
||||
?: error("ResumePositionsStore not initialized — call Resume.init(context)")
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Search-result cache. Holds the last N executed queries and their
|
||||
* result lists so:
|
||||
* - Re-running a recent query paints from cache in one frame.
|
||||
* - Reactive-as-you-type filtering can scan all cached items as
|
||||
* the user types, surfacing matches before they hit Search.
|
||||
*
|
||||
* Sized for SharedPreferences: 30 queries * 20 items each * ~250 bytes
|
||||
* = ~150 KB worst case.
|
||||
*
|
||||
* Backed by a MutableStateFlow loaded once at construction —
|
||||
* record/load are atomic against concurrent calls. audit
|
||||
* B5: the prior load()→edit()→write() pattern would clobber a
|
||||
* concurrent record() with whichever happened to persist last.
|
||||
*
|
||||
* Skips entirely when Settings.cacheEnabled is false — caller checks
|
||||
* the flag before reading/writing.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.sulkta.straw.feature.search.StreamItem
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.updateAndGet
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class SearchCacheEntry(
|
||||
val query: String,
|
||||
val fetchedAt: Long,
|
||||
val items: List<StreamItem>,
|
||||
)
|
||||
|
||||
private const val PREFS = "straw_search_cache"
|
||||
private const val KEY = "search_v1"
|
||||
private const val MAX_QUERIES_HARD = 5000
|
||||
private const val MAX_ITEMS_PER_QUERY = 20
|
||||
|
||||
class SearchCacheStore(context: Context) {
|
||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val _entries = MutableStateFlow(loadFromDisk())
|
||||
val entries: StateFlow<List<SearchCacheEntry>> = _entries.asStateFlow()
|
||||
|
||||
private fun maxQueries(): Int =
|
||||
Settings.get().searchCacheCap.value.value.coerceAtMost(MAX_QUERIES_HARD)
|
||||
|
||||
/**
|
||||
* Filter out entries older than the configured TTL. Called on every
|
||||
* read path so stale data never surfaces. Forever (ttl.isForever)
|
||||
* is a no-op. Returns a fresh list — caller decides whether to
|
||||
* persist the trim.
|
||||
*/
|
||||
private fun filterByTtl(items: List<SearchCacheEntry>): List<SearchCacheEntry> {
|
||||
val ttl = Settings.get().cacheTtl.value
|
||||
if (ttl.isForever) return items
|
||||
val cutoff = System.currentTimeMillis() - ttl.ms
|
||||
return items.filter { it.fetchedAt >= cutoff }
|
||||
}
|
||||
|
||||
/** Snapshot of the cache. Used by the reactive search filter. */
|
||||
fun load(): List<SearchCacheEntry> = filterByTtl(_entries.value)
|
||||
|
||||
/**
|
||||
* Record a freshly-fetched query result. Idempotent: a re-run of
|
||||
* the same query overwrites the prior entry rather than duplicating.
|
||||
* Oldest entries fall off when maxQueries() is exceeded.
|
||||
*
|
||||
* Atomic via updateAndGet — concurrent records don't lose entries.
|
||||
*/
|
||||
fun record(query: String, items: List<StreamItem>) {
|
||||
val q = query.trim()
|
||||
if (q.isEmpty() || items.isEmpty()) return
|
||||
val capped = items.take(MAX_ITEMS_PER_QUERY)
|
||||
val now = System.currentTimeMillis()
|
||||
val next = _entries.updateAndGet { current ->
|
||||
val without = current.filterNot { it.query.equals(q, ignoreCase = true) }
|
||||
(listOf(SearchCacheEntry(q, now, capped)) + without).take(maxQueries())
|
||||
}
|
||||
sp.edit().putString(KEY, json.encodeToString(next)).apply()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_entries.value = emptyList()
|
||||
sp.edit().remove(KEY).apply()
|
||||
}
|
||||
|
||||
private fun loadFromDisk(): List<SearchCacheEntry> = runCatching {
|
||||
val s = sp.getString(KEY, null) ?: return emptyList()
|
||||
json.decodeFromString<List<SearchCacheEntry>>(s)
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
object SearchCache {
|
||||
@Volatile private var appContext: Context? = null
|
||||
@Volatile private var instance: SearchCacheStore? = null
|
||||
|
||||
/** Lazy init — see FeedCache.init for the rationale. */
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
fun get(): SearchCacheStore {
|
||||
instance?.let { return it }
|
||||
synchronized(this) {
|
||||
instance?.let { return it }
|
||||
val ctx = appContext
|
||||
?: error("SearchCacheStore not initialized — call SearchCache.init(context)")
|
||||
val built = SearchCacheStore(ctx)
|
||||
instance = built
|
||||
return built
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,9 +37,108 @@ enum class MaxResolution(val label: String, val ceiling: Int) {
|
|||
P144("144p", 144),
|
||||
}
|
||||
|
||||
enum class ThemeMode(val label: String) {
|
||||
System("Follow system"),
|
||||
Light("Light"),
|
||||
Dark("Dark"),
|
||||
}
|
||||
|
||||
/**
|
||||
* When a video ends with nothing left in the queue, what should the
|
||||
* player do? `Off` stops at the end (matches NewPipe's default).
|
||||
* `SameChannel` chains to the next video from the same uploader —
|
||||
* fits Straw's user-curated ethos (you opted into this channel).
|
||||
* `YtRelated` pulls from `info.related` (YouTube's algorithmic
|
||||
* suggestion); deferred until strawcore populates `related` from
|
||||
* the /next response — for now it's identical to `Off`.
|
||||
*/
|
||||
enum class AutoplayMode(val label: String, val help: String) {
|
||||
Off("Off", "Stop at the end."),
|
||||
SameChannel("Same channel", "Play the next video from the same uploader."),
|
||||
YtRelated("YouTube related", "Pull from YT's related suggestions. (not yet wired — extractor returns empty)"),
|
||||
}
|
||||
|
||||
/**
|
||||
* How often the auto-update worker polls fdroid.sulkta.com. WorkManager
|
||||
* has a 15-minute floor on periodic work, so 1h is the tightest cadence
|
||||
* we expose.
|
||||
*/
|
||||
enum class AutoUpdateInterval(val label: String) {
|
||||
H1("Every hour"),
|
||||
H6("Every 6 hours"),
|
||||
H24("Every 24 hours"),
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing cache caps. Each store's hard limit is the cap's value;
|
||||
* `Int.MAX_VALUE` means "unlimited" (the store grows without trimming).
|
||||
* Defaults match the earlier hardcoded constants so existing data
|
||||
* keeps the same shape until the user picks something different.
|
||||
*/
|
||||
enum class CacheCap(val label: String, val value: Int) {
|
||||
Tiny("50", 50),
|
||||
Small("200", 200),
|
||||
Medium("1000", 1000),
|
||||
Large("10000", 10000),
|
||||
Unlimited("Unlimited", Int.MAX_VALUE);
|
||||
|
||||
companion object {
|
||||
fun nearest(target: Int): CacheCap =
|
||||
entries.firstOrNull { it.value == target } ?: Unlimited
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TTL knob for time-decayed caches (subs feed + search results). 0
|
||||
* means "forever" — entries never time out and only fall off via
|
||||
* size cap. Shorter TTLs reclaim disk on devices with tight storage.
|
||||
*/
|
||||
enum class CacheTtl(val label: String, val days: Int) {
|
||||
D1("1 day", 1),
|
||||
D7("7 days", 7),
|
||||
D30("30 days", 30),
|
||||
D365("1 year", 365),
|
||||
Forever("Forever", 0);
|
||||
|
||||
val isForever: Boolean get() = days == 0
|
||||
val ms: Long get() = days.toLong() * 24L * 60L * 60L * 1000L
|
||||
}
|
||||
|
||||
/**
|
||||
* How often the background subs-feed-refresh worker polls. Defaults to
|
||||
* 1h — tighter than that wastes battery without meaningful freshness
|
||||
* gain (YouTube uploads aren't real-time). Background worker is OFF
|
||||
* by default; opt-in via Settings.
|
||||
*/
|
||||
enum class BgFeedRefreshInterval(val label: String) {
|
||||
M30("Every 30 minutes"),
|
||||
H1("Every hour"),
|
||||
H6("Every 6 hours"),
|
||||
}
|
||||
|
||||
private const val PREFS = "straw_settings"
|
||||
private const val KEY_SB_CATS = "sb_categories_v1"
|
||||
private const val KEY_MAX_RES = "max_resolution_v1"
|
||||
private const val KEY_THEME = "theme_mode_v1"
|
||||
private const val KEY_CACHE_ENABLED = "cache_enabled_v1"
|
||||
private const val KEY_AUTOPLAY_MODE = "autoplay_mode_v1"
|
||||
private const val KEY_AUTOPLAY_SKIP_WATCHED = "autoplay_skip_watched_v1"
|
||||
private const val KEY_AUTOSTART_PLAYBACK = "autostart_playback_v1"
|
||||
private const val KEY_PAUSE_ON_HEADPHONE_DISCONNECT = "pause_on_headphone_disconnect_v1"
|
||||
private const val KEY_AUTO_RESUME = "auto_resume_v1"
|
||||
private const val KEY_AUTO_UPDATE_CHECK = "auto_update_check_v1"
|
||||
private const val KEY_AUTO_UPDATE_INTERVAL = "auto_update_interval_v1"
|
||||
private const val KEY_LAST_UPDATE_CHECK_MS = "last_update_check_ms_v1"
|
||||
private const val KEY_LATEST_KNOWN_VC = "latest_known_vc_v1"
|
||||
private const val KEY_LATEST_KNOWN_VNAME = "latest_known_vname_v1"
|
||||
private const val KEY_HIDE_SHORTS = "hide_shorts_v1"
|
||||
private const val KEY_CACHE_HISTORY_WATCHES = "cache_history_watches_v1"
|
||||
private const val KEY_CACHE_HISTORY_SEARCHES = "cache_history_searches_v1"
|
||||
private const val KEY_CACHE_RESUME_POSITIONS = "cache_resume_positions_v1"
|
||||
private const val KEY_CACHE_SEARCH = "cache_search_v1"
|
||||
private const val KEY_CACHE_TTL = "cache_ttl_v1"
|
||||
private const val KEY_BG_FEED_REFRESH_ENABLED = "bg_feed_refresh_enabled_v1"
|
||||
private const val KEY_BG_FEED_REFRESH_INTERVAL = "bg_feed_refresh_interval_v1"
|
||||
|
||||
class SettingsStore(context: Context) {
|
||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
|
@ -50,6 +149,156 @@ class SettingsStore(context: Context) {
|
|||
private val _maxResolution = MutableStateFlow(loadMaxResolution())
|
||||
val maxResolution: StateFlow<MaxResolution> = _maxResolution.asStateFlow()
|
||||
|
||||
private val _themeMode = MutableStateFlow(loadThemeMode())
|
||||
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
|
||||
|
||||
private val _cacheEnabled = MutableStateFlow(sp.getBoolean(KEY_CACHE_ENABLED, true))
|
||||
val cacheEnabled: StateFlow<Boolean> = _cacheEnabled.asStateFlow()
|
||||
|
||||
private val _autoplayMode = MutableStateFlow(loadAutoplayMode())
|
||||
val autoplayMode: StateFlow<AutoplayMode> = _autoplayMode.asStateFlow()
|
||||
|
||||
private val _autoplaySkipWatched = MutableStateFlow(
|
||||
sp.getBoolean(KEY_AUTOPLAY_SKIP_WATCHED, true),
|
||||
)
|
||||
val autoplaySkipWatched: StateFlow<Boolean> = _autoplaySkipWatched.asStateFlow()
|
||||
|
||||
/**
|
||||
* "Open a video → it starts playing immediately." Default on —
|
||||
* matches YT/NewPipe. When off, opening a fresh video lands you
|
||||
* on the detail page with the thumbnail + Play overlay; you tap
|
||||
* to start. Doesn't affect back-from-fullscreen (that's a
|
||||
* separate path in VideoDetailScreen that defaults to true when
|
||||
* the shared controller is already streaming the URL).
|
||||
*/
|
||||
private val _autoStartPlayback = MutableStateFlow(
|
||||
sp.getBoolean(KEY_AUTOSTART_PLAYBACK, true),
|
||||
)
|
||||
val autoStartPlayback: StateFlow<Boolean> = _autoStartPlayback.asStateFlow()
|
||||
|
||||
/**
|
||||
* Honor Android's AUDIO_BECOMING_NOISY broadcast — wired headphones
|
||||
* yanked / Bluetooth disconnect → pause instead of switching to the
|
||||
* phone speaker. Default on; matches every other Android media app.
|
||||
* Off lets playback follow the audio focus default (phone speaker
|
||||
* takes over).
|
||||
*/
|
||||
private val _pauseOnHeadphoneDisconnect = MutableStateFlow(
|
||||
sp.getBoolean(KEY_PAUSE_ON_HEADPHONE_DISCONNECT, true),
|
||||
)
|
||||
val pauseOnHeadphoneDisconnect: StateFlow<Boolean> = _pauseOnHeadphoneDisconnect.asStateFlow()
|
||||
|
||||
/**
|
||||
* Auto-resume scrub-point on video open. When on (default), opening
|
||||
* a video that has a saved position picks up where the user left
|
||||
* off. When off, every open starts at 0:00. Doesn't affect inline-
|
||||
* ↔ fullscreen hand-off (the shared MediaController keeps its own
|
||||
* position across surfaces; this only matters on fresh opens).
|
||||
*/
|
||||
private val _autoResume = MutableStateFlow(
|
||||
sp.getBoolean(KEY_AUTO_RESUME, true),
|
||||
)
|
||||
val autoResume: StateFlow<Boolean> = _autoResume.asStateFlow()
|
||||
|
||||
/**
|
||||
* Periodic self-update check against fdroid.sulkta.com. Default on
|
||||
* — NewPipe's "user forgets to update for 6 months" failure mode
|
||||
* is the explicit thing we're closing.
|
||||
*/
|
||||
private val _autoUpdateCheck = MutableStateFlow(
|
||||
sp.getBoolean(KEY_AUTO_UPDATE_CHECK, true),
|
||||
)
|
||||
val autoUpdateCheck: StateFlow<Boolean> = _autoUpdateCheck.asStateFlow()
|
||||
|
||||
private val _autoUpdateInterval = MutableStateFlow(loadAutoUpdateInterval())
|
||||
val autoUpdateInterval: StateFlow<AutoUpdateInterval> = _autoUpdateInterval.asStateFlow()
|
||||
|
||||
/** Last successful poll wall-clock ms; 0 if never. */
|
||||
private val _lastUpdateCheckMs = MutableStateFlow(
|
||||
sp.getLong(KEY_LAST_UPDATE_CHECK_MS, 0L),
|
||||
)
|
||||
val lastUpdateCheckMs: StateFlow<Long> = _lastUpdateCheckMs.asStateFlow()
|
||||
|
||||
/**
|
||||
* Cached "latest version seen on fdroid" — 0 / "" while none known
|
||||
* or while caught-up. Lets SettingsScreen show "an update available"
|
||||
* without re-polling.
|
||||
*/
|
||||
private val _latestKnownVc = MutableStateFlow(
|
||||
sp.getLong(KEY_LATEST_KNOWN_VC, 0L),
|
||||
)
|
||||
val latestKnownVc: StateFlow<Long> = _latestKnownVc.asStateFlow()
|
||||
|
||||
private val _latestKnownVname = MutableStateFlow(
|
||||
sp.getString(KEY_LATEST_KNOWN_VNAME, "") ?: "",
|
||||
)
|
||||
val latestKnownVname: StateFlow<String> = _latestKnownVname.asStateFlow()
|
||||
|
||||
/**
|
||||
* Hide YouTube Shorts everywhere. Detection is multi-signal because
|
||||
* each surface gives different hints:
|
||||
* - Search + ChannelScreen results: URL pattern `/shorts/<id>` is
|
||||
* reliable (strawcore preserves it).
|
||||
* - Subscription RSS feed: URLs come back as canonical `watch?v=`
|
||||
* so URL alone won't trip; fall back to title containing
|
||||
* "#shorts" / "#Shorts" / "(shorts)" which most short uploaders
|
||||
* include.
|
||||
* Filter is best-effort — a hand-tagged short with a clean title
|
||||
* in the subs feed will slip through until a future build plumbs an
|
||||
* isShort flag through strawcore-core.
|
||||
*/
|
||||
private val _hideShorts = MutableStateFlow(
|
||||
sp.getBoolean(KEY_HIDE_SHORTS, false),
|
||||
)
|
||||
val hideShorts: StateFlow<Boolean> = _hideShorts.asStateFlow()
|
||||
|
||||
/**
|
||||
* Per-store cache caps. Each store reads its cap from the matching
|
||||
* StateFlow on every prune cycle so flipping the toggle in Settings
|
||||
* takes effect immediately (next write trims to the new cap; reads
|
||||
* are unbounded since they're already in memory).
|
||||
*
|
||||
* Defaults match the earlier hardcoded constants so first-launch
|
||||
* behavior is unchanged from prior versions.
|
||||
*/
|
||||
private val _historyWatchesCap = MutableStateFlow(
|
||||
CacheCap.nearest(sp.getInt(KEY_CACHE_HISTORY_WATCHES, 50)),
|
||||
)
|
||||
val historyWatchesCap: StateFlow<CacheCap> = _historyWatchesCap.asStateFlow()
|
||||
|
||||
private val _historySearchesCap = MutableStateFlow(
|
||||
loadCap(KEY_CACHE_HISTORY_SEARCHES, default = 20),
|
||||
)
|
||||
val historySearchesCap: StateFlow<CacheCap> = _historySearchesCap.asStateFlow()
|
||||
|
||||
private val _resumePositionsCap = MutableStateFlow(
|
||||
loadCap(KEY_CACHE_RESUME_POSITIONS, default = 500),
|
||||
)
|
||||
val resumePositionsCap: StateFlow<CacheCap> = _resumePositionsCap.asStateFlow()
|
||||
|
||||
private val _searchCacheCap = MutableStateFlow(
|
||||
loadCap(KEY_CACHE_SEARCH, default = 30),
|
||||
)
|
||||
val searchCacheCap: StateFlow<CacheCap> = _searchCacheCap.asStateFlow()
|
||||
|
||||
private val _cacheTtl = MutableStateFlow(loadCacheTtl())
|
||||
val cacheTtl: StateFlow<CacheTtl> = _cacheTtl.asStateFlow()
|
||||
|
||||
/**
|
||||
* Background subscription-feed refresh — WorkManager periodic job
|
||||
* that pre-warms FeedCache so the next cold open paints a fresh
|
||||
* feed without pull-to-refresh. Off by default; cell-network
|
||||
* battery cost is the explicit opt-in.
|
||||
*/
|
||||
private val _bgFeedRefreshEnabled = MutableStateFlow(
|
||||
sp.getBoolean(KEY_BG_FEED_REFRESH_ENABLED, false),
|
||||
)
|
||||
val bgFeedRefreshEnabled: StateFlow<Boolean> = _bgFeedRefreshEnabled.asStateFlow()
|
||||
|
||||
private val _bgFeedRefreshInterval = MutableStateFlow(loadBgFeedInterval())
|
||||
val bgFeedRefreshInterval: StateFlow<BgFeedRefreshInterval> =
|
||||
_bgFeedRefreshInterval.asStateFlow()
|
||||
|
||||
fun toggle(cat: SbCategory) {
|
||||
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
|
||||
val next = _sbCategories.updateAndGet { cur ->
|
||||
|
|
@ -58,11 +307,158 @@ class SettingsStore(context: Context) {
|
|||
sp.edit().putStringSet(KEY_SB_CATS, next.map { it.key }.toSet()).apply()
|
||||
}
|
||||
|
||||
// Atomic + idempotent. Capture before-state, update in-memory,
|
||||
// skip the SP write when the value didn't actually change. The
|
||||
// prior shape used `updateAndGet { r } == r` which is unconditionally
|
||||
// true (the lambda ignores prior) — dead code that confused readers.
|
||||
fun setMaxResolution(r: MaxResolution) {
|
||||
val before = _maxResolution.value
|
||||
if (before == r) return
|
||||
_maxResolution.value = r
|
||||
sp.edit().putString(KEY_MAX_RES, r.name).apply()
|
||||
}
|
||||
|
||||
fun setThemeMode(t: ThemeMode) {
|
||||
val before = _themeMode.value
|
||||
if (before == t) return
|
||||
_themeMode.value = t
|
||||
sp.edit().putString(KEY_THEME, t.name).apply()
|
||||
}
|
||||
|
||||
fun setCacheEnabled(enabled: Boolean) {
|
||||
val before = _cacheEnabled.value
|
||||
if (before == enabled) return
|
||||
_cacheEnabled.value = enabled
|
||||
sp.edit().putBoolean(KEY_CACHE_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
fun setAutoplayMode(mode: AutoplayMode) {
|
||||
val before = _autoplayMode.value
|
||||
if (before == mode) return
|
||||
_autoplayMode.value = mode
|
||||
sp.edit().putString(KEY_AUTOPLAY_MODE, mode.name).apply()
|
||||
}
|
||||
|
||||
fun setAutoplaySkipWatched(skip: Boolean) {
|
||||
val before = _autoplaySkipWatched.value
|
||||
if (before == skip) return
|
||||
_autoplaySkipWatched.value = skip
|
||||
sp.edit().putBoolean(KEY_AUTOPLAY_SKIP_WATCHED, skip).apply()
|
||||
}
|
||||
|
||||
fun setAutoStartPlayback(autoStart: Boolean) {
|
||||
val before = _autoStartPlayback.value
|
||||
if (before == autoStart) return
|
||||
_autoStartPlayback.value = autoStart
|
||||
sp.edit().putBoolean(KEY_AUTOSTART_PLAYBACK, autoStart).apply()
|
||||
}
|
||||
|
||||
fun setPauseOnHeadphoneDisconnect(pause: Boolean) {
|
||||
val before = _pauseOnHeadphoneDisconnect.value
|
||||
if (before == pause) return
|
||||
_pauseOnHeadphoneDisconnect.value = pause
|
||||
sp.edit().putBoolean(KEY_PAUSE_ON_HEADPHONE_DISCONNECT, pause).apply()
|
||||
}
|
||||
|
||||
fun setAutoResume(enabled: Boolean) {
|
||||
val before = _autoResume.value
|
||||
if (before == enabled) return
|
||||
_autoResume.value = enabled
|
||||
sp.edit().putBoolean(KEY_AUTO_RESUME, enabled).apply()
|
||||
}
|
||||
|
||||
fun setAutoUpdateCheck(enabled: Boolean) {
|
||||
val before = _autoUpdateCheck.value
|
||||
if (before == enabled) return
|
||||
_autoUpdateCheck.value = enabled
|
||||
sp.edit().putBoolean(KEY_AUTO_UPDATE_CHECK, enabled).apply()
|
||||
}
|
||||
|
||||
fun setAutoUpdateInterval(interval: AutoUpdateInterval) {
|
||||
val before = _autoUpdateInterval.value
|
||||
if (before == interval) return
|
||||
_autoUpdateInterval.value = interval
|
||||
sp.edit().putString(KEY_AUTO_UPDATE_INTERVAL, interval.name).apply()
|
||||
}
|
||||
|
||||
fun setLastUpdateCheck(ms: Long) {
|
||||
_lastUpdateCheckMs.value = ms
|
||||
sp.edit().putLong(KEY_LAST_UPDATE_CHECK_MS, ms).apply()
|
||||
}
|
||||
|
||||
fun setLatestKnownVersion(vc: Long, vname: String) {
|
||||
_latestKnownVc.value = vc
|
||||
_latestKnownVname.value = vname
|
||||
sp.edit()
|
||||
.putLong(KEY_LATEST_KNOWN_VC, vc)
|
||||
.putString(KEY_LATEST_KNOWN_VNAME, vname)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun setHideShorts(hide: Boolean) {
|
||||
val before = _hideShorts.value
|
||||
if (before == hide) return
|
||||
_hideShorts.value = hide
|
||||
sp.edit().putBoolean(KEY_HIDE_SHORTS, hide).apply()
|
||||
}
|
||||
|
||||
fun setHistoryWatchesCap(cap: CacheCap) {
|
||||
if (_historyWatchesCap.value == cap) return
|
||||
_historyWatchesCap.value = cap
|
||||
sp.edit().putInt(KEY_CACHE_HISTORY_WATCHES, cap.value).apply()
|
||||
}
|
||||
|
||||
fun setHistorySearchesCap(cap: CacheCap) {
|
||||
if (_historySearchesCap.value == cap) return
|
||||
_historySearchesCap.value = cap
|
||||
sp.edit().putInt(KEY_CACHE_HISTORY_SEARCHES, cap.value).apply()
|
||||
}
|
||||
|
||||
fun setResumePositionsCap(cap: CacheCap) {
|
||||
if (_resumePositionsCap.value == cap) return
|
||||
_resumePositionsCap.value = cap
|
||||
sp.edit().putInt(KEY_CACHE_RESUME_POSITIONS, cap.value).apply()
|
||||
}
|
||||
|
||||
fun setSearchCacheCap(cap: CacheCap) {
|
||||
if (_searchCacheCap.value == cap) return
|
||||
_searchCacheCap.value = cap
|
||||
sp.edit().putInt(KEY_CACHE_SEARCH, cap.value).apply()
|
||||
}
|
||||
|
||||
fun setCacheTtl(ttl: CacheTtl) {
|
||||
if (_cacheTtl.value == ttl) return
|
||||
_cacheTtl.value = ttl
|
||||
sp.edit().putString(KEY_CACHE_TTL, ttl.name).apply()
|
||||
}
|
||||
|
||||
fun setBgFeedRefreshEnabled(enabled: Boolean) {
|
||||
if (_bgFeedRefreshEnabled.value == enabled) return
|
||||
_bgFeedRefreshEnabled.value = enabled
|
||||
sp.edit().putBoolean(KEY_BG_FEED_REFRESH_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
fun setBgFeedRefreshInterval(interval: BgFeedRefreshInterval) {
|
||||
if (_bgFeedRefreshInterval.value == interval) return
|
||||
_bgFeedRefreshInterval.value = interval
|
||||
sp.edit().putString(KEY_BG_FEED_REFRESH_INTERVAL, interval.name).apply()
|
||||
}
|
||||
|
||||
private fun loadCap(key: String, default: Int): CacheCap =
|
||||
CacheCap.nearest(sp.getInt(key, default))
|
||||
|
||||
private fun loadCacheTtl(): CacheTtl {
|
||||
val name = sp.getString(KEY_CACHE_TTL, null) ?: return CacheTtl.D30
|
||||
return CacheTtl.entries.firstOrNull { it.name == name } ?: CacheTtl.D30
|
||||
}
|
||||
|
||||
private fun loadBgFeedInterval(): BgFeedRefreshInterval {
|
||||
val name = sp.getString(KEY_BG_FEED_REFRESH_INTERVAL, null)
|
||||
?: return BgFeedRefreshInterval.H1
|
||||
return BgFeedRefreshInterval.entries.firstOrNull { it.name == name }
|
||||
?: BgFeedRefreshInterval.H1
|
||||
}
|
||||
|
||||
private fun loadCategories(): Set<SbCategory> {
|
||||
val raw = sp.getStringSet(KEY_SB_CATS, null)
|
||||
return if (raw == null) {
|
||||
|
|
@ -77,6 +473,26 @@ class SettingsStore(context: Context) {
|
|||
val name = sp.getString(KEY_MAX_RES, null) ?: return MaxResolution.Auto
|
||||
return MaxResolution.entries.firstOrNull { it.name == name } ?: MaxResolution.Auto
|
||||
}
|
||||
|
||||
private fun loadThemeMode(): ThemeMode {
|
||||
val name = sp.getString(KEY_THEME, null) ?: return ThemeMode.System
|
||||
return ThemeMode.entries.firstOrNull { it.name == name } ?: ThemeMode.System
|
||||
}
|
||||
|
||||
private fun loadAutoplayMode(): AutoplayMode {
|
||||
// Default to SameChannel — user explicitly chose "on by default,
|
||||
// plays next account's video" 2026-05-26. Off-by-default doesn't
|
||||
// fit the workflow (queue empties → silence).
|
||||
val name = sp.getString(KEY_AUTOPLAY_MODE, null) ?: return AutoplayMode.SameChannel
|
||||
return AutoplayMode.entries.firstOrNull { it.name == name } ?: AutoplayMode.SameChannel
|
||||
}
|
||||
|
||||
private fun loadAutoUpdateInterval(): AutoUpdateInterval {
|
||||
val name = sp.getString(KEY_AUTO_UPDATE_INTERVAL, null)
|
||||
?: return AutoUpdateInterval.H6
|
||||
return AutoUpdateInterval.entries.firstOrNull { it.name == name }
|
||||
?: AutoUpdateInterval.H6
|
||||
}
|
||||
}
|
||||
|
||||
object Settings {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* SharedPreferences-lite subscription list. Day-4 graduates to Room when
|
||||
* we want background feed fetching for new uploads.
|
||||
* Subscription list backed by a single JSON blob in SharedPreferences.
|
||||
* Graduates to Room when background feed fetching arrives.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.data
|
||||
|
|
@ -29,7 +29,7 @@ private const val KEY = "subs_v1"
|
|||
|
||||
class SubscriptionsStore(context: Context) {
|
||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val _subs = MutableStateFlow(load())
|
||||
val subs: StateFlow<List<ChannelRef>> = _subs.asStateFlow()
|
||||
|
|
@ -38,7 +38,9 @@ class SubscriptionsStore(context: Context) {
|
|||
_subs.value.any { it.url == channelUrl }
|
||||
|
||||
fun toggle(ref: ChannelRef) {
|
||||
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
|
||||
// updateAndGet makes the read-modify-write atomic vs. concurrent
|
||||
// toggles (e.g. one channel subscribed from the feed while another
|
||||
// is unsubscribed from VideoDetail).
|
||||
val next = _subs.updateAndGet { cur ->
|
||||
if (cur.any { it.url == ref.url }) {
|
||||
cur.filterNot { it.url == ref.url }
|
||||
|
|
@ -49,9 +51,57 @@ class SubscriptionsStore(context: Context) {
|
|||
persist(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cached avatar for an already-subscribed channel. Used
|
||||
* by the subs feed fetch when it pulls a fresh ChannelInfo and the
|
||||
* stored ChannelRef has a null avatar (channel header parser missed
|
||||
* it at subscribe time). No-op for non-subscribed URLs.
|
||||
*/
|
||||
fun updateAvatar(channelUrl: String, avatar: String) {
|
||||
val next = _subs.updateAndGet { cur ->
|
||||
cur.map { if (it.url == channelUrl) it.copy(avatar = avatar) else it }
|
||||
}
|
||||
persist(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-add. Single persist instead of N. Per-call `toggle()` was
|
||||
* O(N²) + N SP writes, which the security audit flagged as
|
||||
* a DoS vector for hostile NewPipe-export imports. Single linear
|
||||
* scan to dedup, one persist regardless of input size. Returns the
|
||||
* count of NEW (not previously-subscribed) channels added so the
|
||||
* caller can report an "added X" stat.
|
||||
*/
|
||||
fun addAll(refs: List<ChannelRef>): Int {
|
||||
// Count NEW refs by checking each input URL against the
|
||||
// current state's pre-image inside the CAS lambda. Captures
|
||||
// exactly the additions this call made — concurrent
|
||||
// toggles that race the CAS don't inflate the count (
|
||||
// ). The counter lives in an
|
||||
// AtomicInteger so each lambda re-run resets it correctly.
|
||||
val counter = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
val next = _subs.updateAndGet { state ->
|
||||
counter.set(0)
|
||||
val byUrl = state.associateBy { it.url }.toMutableMap()
|
||||
for (r in refs) {
|
||||
if (r.url.isBlank()) continue
|
||||
if (r.url !in byUrl) {
|
||||
byUrl[r.url] = r
|
||||
counter.incrementAndGet()
|
||||
}
|
||||
}
|
||||
byUrl.values.toList()
|
||||
}
|
||||
persist(next)
|
||||
return counter.get()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_subs.value = emptyList()
|
||||
sp.edit().remove(KEY).apply()
|
||||
// Same atomic-update path as toggle — protects against a concurrent
|
||||
// toggle racing the clear and persisting [new-item] after the
|
||||
// remove() call has fired.
|
||||
_subs.updateAndGet { emptyList() }
|
||||
persist(emptyList())
|
||||
}
|
||||
|
||||
private fun persist(list: List<ChannelRef>) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,9 @@
|
|||
|
||||
package com.sulkta.straw.feature.channel
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -41,13 +43,20 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import com.sulkta.straw.feature.player.VideoThumbnail
|
||||
import com.sulkta.straw.data.ChannelRef
|
||||
import com.sulkta.straw.data.Subscriptions
|
||||
import com.sulkta.straw.feature.playlist.VideoActionTarget
|
||||
import com.sulkta.straw.feature.playlist.VideoActionsSheet
|
||||
import com.sulkta.straw.feature.search.StreamItem
|
||||
import com.sulkta.straw.util.formatCount
|
||||
import com.sulkta.straw.util.rememberBottomContentPadding
|
||||
import com.sulkta.straw.util.formatDuration
|
||||
|
||||
@Composable
|
||||
|
|
@ -61,8 +70,22 @@ fun ChannelScreen(
|
|||
LaunchedEffect(channelUrl) { vm.load(channelUrl) }
|
||||
val subs by Subscriptions.get().subs.collectAsState()
|
||||
val subscribed = subs.any { it.url == channelUrl }
|
||||
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
|
||||
actionTarget?.let { t ->
|
||||
VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
|
||||
}
|
||||
|
||||
when {
|
||||
// Stale-state gate: activity-scoped VM, so when we navigate A → B
|
||||
// the screen recomposes once with A's state before vm.load(B)
|
||||
// resets it. Without this branch we'd render channel A's banner /
|
||||
// name / videos under URL B. Same shape as VideoDetailScreen's
|
||||
// gate.
|
||||
state.loadedUrl != channelUrl -> Box(
|
||||
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
|
||||
state.loading -> Box(
|
||||
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
||||
contentAlignment = Alignment.Center,
|
||||
|
|
@ -75,7 +98,18 @@ fun ChannelScreen(
|
|||
Text("error: ${state.error}", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
|
||||
else -> {
|
||||
// Hoisted to outer Composable scope — LazyListScope is NOT
|
||||
// @Composable so collectAsState / remember can't live inside
|
||||
// the LazyColumn block.
|
||||
val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState()
|
||||
val filteredVideos = remember(state.videos, hideShorts) {
|
||||
com.sulkta.straw.util.applyContentFilters(state.videos, hideShorts = hideShorts)
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
||||
contentPadding = rememberBottomContentPadding(),
|
||||
) {
|
||||
item {
|
||||
state.banner?.let { b ->
|
||||
AsyncImage(
|
||||
|
|
@ -129,30 +163,47 @@ fun ChannelScreen(
|
|||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
items(state.videos) { item ->
|
||||
ChannelVideoRow(item) { onOpenVideo(item.url, item.title) }
|
||||
items(filteredVideos) { item ->
|
||||
ChannelVideoRow(
|
||||
item = item,
|
||||
onClick = { onOpenVideo(item.url, item.title) },
|
||||
onLongClick = {
|
||||
actionTarget = VideoActionTarget(
|
||||
streamUrl = item.url,
|
||||
title = item.title,
|
||||
uploader = item.uploader,
|
||||
thumbnail = item.thumbnail,
|
||||
)
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ChannelVideoRow(item: StreamItem, onClick: () -> Unit) {
|
||||
private fun ChannelVideoRow(
|
||||
item: StreamItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.thumbnail,
|
||||
contentDescription = null,
|
||||
VideoThumbnail(
|
||||
thumbnail = item.thumbnail,
|
||||
videoUrl = item.url,
|
||||
durationSeconds = item.durationSeconds,
|
||||
modifier = Modifier
|
||||
.width(140.dp)
|
||||
.height(80.dp)
|
||||
.clip(RoundedCornerShape(6.dp)),
|
||||
.height(80.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
|
|
@ -164,18 +215,26 @@ private fun ChannelVideoRow(item: StreamItem, onClick: () -> Unit) {
|
|||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = buildString {
|
||||
if (item.viewCount > 0) append("${formatCount(item.viewCount)} views")
|
||||
if (item.durationSeconds > 0) {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append(formatDuration(item.durationSeconds))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
// Don't repeat duration here — VideoThumbnail's
|
||||
// bottom-right badge already shows it. Add the upload
|
||||
// date so the row reads 'N views · 2 days ago' the way
|
||||
// YT renders it. The earlier row was duplicating duration
|
||||
// and missing the upload date on the channel page.
|
||||
val meta = buildString {
|
||||
if (item.viewCount > 0) append("${formatCount(item.viewCount)} views")
|
||||
if (item.uploadDateRelative.isNotBlank()) {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append(item.uploadDateRelative)
|
||||
}
|
||||
}
|
||||
if (meta.isNotEmpty()) {
|
||||
Text(
|
||||
text = meta,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Phase U-4 / Path C-5: ChannelInfo + Videos tab moved to strawcore
|
||||
* (rustypipe). The two separate ChannelInfo.getInfo + ChannelTabInfo.getInfo
|
||||
* calls collapse into one Rust round-trip.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.channel
|
||||
|
|
@ -8,19 +12,14 @@ package com.sulkta.straw.feature.channel
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.sulkta.straw.feature.search.StreamItem
|
||||
import com.sulkta.straw.util.bestThumbnail
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.ServiceList
|
||||
import com.sulkta.straw.util.isAllowedYtUrl
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
|
||||
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
|
||||
data class ChannelUiState(
|
||||
val loading: Boolean = true,
|
||||
|
|
@ -30,60 +29,94 @@ data class ChannelUiState(
|
|||
val avatar: String? = null,
|
||||
val videos: List<StreamItem> = emptyList(),
|
||||
val error: String? = null,
|
||||
/**
|
||||
* Tracks which channel URL the current state belongs to. Same
|
||||
* activity-scoped-VM hazard as VideoDetail: a fresh nav to
|
||||
* channel B sees the PREVIOUS channel's state for one composition
|
||||
* frame before vm.load(B) clears it. Without this field, any
|
||||
* caller that derives "this is the channel we want" from
|
||||
* `state.name` (or other display fields) is reading channel A's
|
||||
* data while believing it's B.
|
||||
*/
|
||||
val loadedUrl: String? = null,
|
||||
)
|
||||
|
||||
class ChannelViewModel : ViewModel() {
|
||||
private val _ui = MutableStateFlow(ChannelUiState())
|
||||
val ui: StateFlow<ChannelUiState> = _ui.asStateFlow()
|
||||
|
||||
fun load(channelUrl: String) {
|
||||
_ui.value = ChannelUiState(loading = true)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
ChannelInfo.getInfo(service, channelUrl)
|
||||
}
|
||||
// AUD-HIGH: pick the Videos tab specifically rather than
|
||||
// info.tabs.firstOrNull() which is YouTube's "Home" (a
|
||||
// curated mix that mostly drops via filterIsInstance).
|
||||
val videosTab = info.tabs.firstOrNull {
|
||||
it.contentFilters.contains(ChannelTabs.VIDEOS)
|
||||
} ?: info.tabs.firstOrNull()
|
||||
val videos: List<StreamItem> = if (videosTab != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
ChannelTabInfo.getInfo(service, videosTab)
|
||||
.relatedItems
|
||||
.filterIsInstance<StreamInfoItem>()
|
||||
.map {
|
||||
StreamItem(
|
||||
url = it.url,
|
||||
title = it.name ?: "(no title)",
|
||||
uploader = it.uploaderName ?: info.name ?: "",
|
||||
uploaderUrl = it.uploaderUrl ?: channelUrl,
|
||||
thumbnail = bestThumbnail(it.thumbnails),
|
||||
durationSeconds = it.duration,
|
||||
viewCount = it.viewCount,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
} else emptyList()
|
||||
// Track the active load coroutine — same shape as
|
||||
// VideoDetailViewModel. Rapid channel switches no longer race;
|
||||
// the late-arriving older fetch is cancelled.
|
||||
// / MED-1.
|
||||
private var inFlight: Job? = null
|
||||
|
||||
_ui.value = ChannelUiState(
|
||||
fun load(channelUrl: String) {
|
||||
// Snapshot _ui once so the two reads agree.
|
||||
val snap = _ui.value
|
||||
if (snap.loadedUrl == channelUrl && snap.videos.isNotEmpty()) return
|
||||
// extractor-emitted uploaderUrl can be
|
||||
// attacker-controlled if the YT response is poisoned upstream.
|
||||
// Refuse non-YT hosts at the entry point so we don't even
|
||||
// issue a network call to evil.com via strawcore. Also cancel
|
||||
// inFlight on rejection so a still-resolving prior load can't
|
||||
// clobber the error banner.
|
||||
if (!isAllowedYtUrl(channelUrl)) {
|
||||
inFlight?.cancel()
|
||||
inFlight = null
|
||||
_ui.update {
|
||||
ChannelUiState(
|
||||
loading = false,
|
||||
name = info.name ?: "",
|
||||
subscriberCount = info.subscriberCount,
|
||||
banner = bestThumbnail(info.banners),
|
||||
avatar = bestThumbnail(info.avatars),
|
||||
videos = videos,
|
||||
error = "Unsupported URL",
|
||||
loadedUrl = channelUrl,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
inFlight?.cancel()
|
||||
_ui.update { ChannelUiState(loading = true, loadedUrl = channelUrl) }
|
||||
inFlight = viewModelScope.launch {
|
||||
try {
|
||||
val ch = uniffi.strawcore.channelInfo(channelUrl)
|
||||
val videos = ch.videos.map { v ->
|
||||
StreamItem(
|
||||
url = v.url,
|
||||
title = v.title.ifBlank { "(no title)" },
|
||||
uploader = v.uploader,
|
||||
uploaderUrl = v.uploaderUrl,
|
||||
thumbnail = v.thumbnail,
|
||||
durationSeconds = v.durationSeconds,
|
||||
viewCount = v.viewCount,
|
||||
uploadDateRelative = v.uploadDateRelative,
|
||||
)
|
||||
}
|
||||
if (_ui.value.loadedUrl != channelUrl) return@launch
|
||||
_ui.update {
|
||||
ChannelUiState(
|
||||
loading = false,
|
||||
name = ch.name,
|
||||
subscriberCount = ch.subscriberCount,
|
||||
banner = ch.banner,
|
||||
avatar = ch.avatar,
|
||||
videos = videos,
|
||||
loadedUrl = channelUrl,
|
||||
)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
_ui.value = ChannelUiState(
|
||||
loading = false,
|
||||
error = t.message ?: t.javaClass.simpleName,
|
||||
)
|
||||
if (t is CancellationException) throw t
|
||||
if (_ui.value.loadedUrl != channelUrl) return@launch
|
||||
_ui.update {
|
||||
ChannelUiState(
|
||||
loading = false,
|
||||
// Scrub before storing — UniFFI/Rust exceptions
|
||||
// can embed full signed googlevideo URLs in the
|
||||
// message (NetworkError::Recaptcha { url }).
|
||||
error = com.sulkta.straw.util.LogDump.scrubLine(
|
||||
t.message ?: t.javaClass.simpleName,
|
||||
),
|
||||
loadedUrl = channelUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,554 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* NewPipe / Tubular export importer.
|
||||
*
|
||||
* The user picks an exported `.zip` (NewPipe writes it as
|
||||
* `NewPipeData-<date>.zip`, Tubular as `TubularData-<date>.zip`).
|
||||
* Inside:
|
||||
* - newpipe.db Room SQLite (subscriptions, playlists, history…)
|
||||
* - preferences.json flat key/value of all user settings
|
||||
* - newpipe.settings superseded XML form of preferences (we ignore)
|
||||
*
|
||||
* We populate Straw's existing stores (Subscriptions, Playlists, History,
|
||||
* Settings) — filtering to service_id=0 (YouTube). Other services
|
||||
* (SoundCloud / PeerTube / …) are silently dropped — we don't support
|
||||
* them and a mixed import would surprise the user later.
|
||||
*
|
||||
* Resume positions (NewPipe `stream_state` table) are read but
|
||||
* intentionally not persisted yet — Straw has no resume-positions
|
||||
* store. Counted in [ImportResult.resumePositionsSeen] so the user
|
||||
* knows the data was present even if dropped.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.dataimport
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.net.Uri
|
||||
import com.sulkta.straw.data.ChannelRef
|
||||
import com.sulkta.straw.data.History
|
||||
import com.sulkta.straw.data.PlaylistItem
|
||||
import com.sulkta.straw.data.Playlists
|
||||
import com.sulkta.straw.data.SbCategory
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.data.Subscriptions
|
||||
import com.sulkta.straw.data.WatchHistoryItem
|
||||
import java.io.File
|
||||
import java.util.zip.ZipInputStream
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
data class ImportResult(
|
||||
val subscriptionsAdded: Int,
|
||||
val subscriptionsSkippedNonYt: Int,
|
||||
val playlistsAdded: Int,
|
||||
val playlistItemsAdded: Int,
|
||||
val searchHistoryAdded: Int,
|
||||
val searchHistoryAvailable: Int,
|
||||
val watchHistoryAdded: Int,
|
||||
val watchHistoryAvailable: Int,
|
||||
val resumePositionsSeen: Int,
|
||||
val settingsApplied: Int,
|
||||
val warnings: List<String>,
|
||||
) {
|
||||
fun summary(): String = buildString {
|
||||
append("Imported ")
|
||||
append(subscriptionsAdded)
|
||||
append(" subs")
|
||||
if (subscriptionsSkippedNonYt > 0) {
|
||||
append(" (skipped ")
|
||||
append(subscriptionsSkippedNonYt)
|
||||
append(" non-YouTube)")
|
||||
}
|
||||
append(", ")
|
||||
append(playlistsAdded)
|
||||
append(" playlist")
|
||||
if (playlistsAdded != 1) append("s")
|
||||
append(" (")
|
||||
append(playlistItemsAdded)
|
||||
append(" videos), ")
|
||||
append(watchHistoryAdded)
|
||||
append("/")
|
||||
append(watchHistoryAvailable)
|
||||
append(" watch history, ")
|
||||
append(searchHistoryAdded)
|
||||
append("/")
|
||||
append(searchHistoryAvailable)
|
||||
append(" searches, ")
|
||||
append(settingsApplied)
|
||||
append(" settings.")
|
||||
if (resumePositionsSeen > 0) {
|
||||
append(" Resume positions (")
|
||||
append(resumePositionsSeen)
|
||||
append(") not yet supported — dropped.")
|
||||
}
|
||||
if (warnings.isNotEmpty()) {
|
||||
append("\n\nWarnings:\n")
|
||||
warnings.forEach { append("• "); append(it); append("\n") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object SettingsImport {
|
||||
|
||||
// YouTube only — Straw doesn't extract from other services.
|
||||
private const val YT_SERVICE_ID = 0
|
||||
|
||||
// The allowlist itself lives in util.YtUrl now — VideoDetailViewModel
|
||||
// also gates auto-channelInfo + recordWatch through it.
|
||||
private fun isAllowedYtUrl(url: String): Boolean =
|
||||
com.sulkta.straw.util.isAllowedYtUrl(url)
|
||||
|
||||
suspend fun run(context: Context, zipUri: Uri): Result<ImportResult> =
|
||||
withContext(Dispatchers.IO) {
|
||||
// runInner is suspend (it switches to NonCancellable for
|
||||
// cleanup). Plain runCatching would swallow a user-back
|
||||
// CancellationException and surface it as a normal
|
||||
// failure with a misleading banner.
|
||||
com.sulkta.straw.util.runCatchingCancellable {
|
||||
runInner(context, zipUri)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep stale import work-dirs left behind by a previous run that
|
||||
* was killed mid-extraction. CRIT from the security audit:
|
||||
* a force-killed import leaves the user's full newpipe.db sitting
|
||||
* in cacheDir indefinitely. StrawApp.onCreate calls this on every
|
||||
* cold start.
|
||||
*/
|
||||
fun sweepStale(context: Context) {
|
||||
runCatching {
|
||||
context.cacheDir.listFiles { f ->
|
||||
f.isDirectory && f.name.startsWith("newpipe-import-")
|
||||
}?.forEach { it.deleteRecursively() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runInner(context: Context, zipUri: Uri): ImportResult {
|
||||
val warnings = mutableListOf<String>()
|
||||
// createTempFile returns an unguessable name and 0600 perms by
|
||||
// default, replacing the predictable currentTimeMillis suffix
|
||||
// that an attacker could pre-create a symlink at.
|
||||
val workDir = File.createTempFile("newpipe-import-", "", context.cacheDir).also {
|
||||
it.delete(); it.mkdirs()
|
||||
}
|
||||
try {
|
||||
val (dbFile, prefsJson) = extractZip(context, zipUri, workDir, warnings)
|
||||
|
||||
val subsResult = if (dbFile != null) importSubscriptions(dbFile) else SubsResult(0, 0)
|
||||
val plResult = if (dbFile != null) importPlaylists(dbFile) else PlResult(0, 0)
|
||||
val histResult = if (dbFile != null) importHistory(dbFile) else HistResult(0, 0, 0, 0, 0)
|
||||
val settingsResult = if (prefsJson != null) importSettings(prefsJson) else 0
|
||||
|
||||
return ImportResult(
|
||||
subscriptionsAdded = subsResult.added,
|
||||
subscriptionsSkippedNonYt = subsResult.skipped,
|
||||
playlistsAdded = plResult.playlists,
|
||||
playlistItemsAdded = plResult.items,
|
||||
searchHistoryAdded = histResult.searches,
|
||||
searchHistoryAvailable = histResult.searchesAvailable,
|
||||
watchHistoryAdded = histResult.watchesAdded,
|
||||
watchHistoryAvailable = histResult.watchesAvailable,
|
||||
resumePositionsSeen = histResult.resumePositions,
|
||||
settingsApplied = settingsResult,
|
||||
warnings = warnings,
|
||||
)
|
||||
} finally {
|
||||
// NonCancellable guarantees the cleanup runs even when the
|
||||
// outer coroutine was cancelled — without it a user
|
||||
// navigating away mid-import (or low-memory killer firing)
|
||||
// left the full newpipe.db in cacheDir until the next
|
||||
// cold-start sweep.
|
||||
withContext(NonCancellable) {
|
||||
workDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Defense against zip-bomb / malformed exports.
|
||||
private const val MAX_DB_BYTES: Long = 256L * 1024 * 1024
|
||||
private const val MAX_PREFS_BYTES: Long = 1L * 1024 * 1024
|
||||
private const val MAX_ZIP_ENTRIES: Int = 64
|
||||
|
||||
private fun extractZip(
|
||||
context: Context,
|
||||
zipUri: Uri,
|
||||
workDir: File,
|
||||
warnings: MutableList<String>,
|
||||
): Pair<File?, JsonObject?> {
|
||||
var dbFile: File? = null
|
||||
var prefs: JsonObject? = null
|
||||
var entryCount = 0
|
||||
context.contentResolver.openInputStream(zipUri)?.use { input ->
|
||||
ZipInputStream(input).use { zip ->
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
entryCount++
|
||||
if (entryCount > MAX_ZIP_ENTRIES) {
|
||||
warnings += "archive has >$MAX_ZIP_ENTRIES entries — aborting"
|
||||
return null to null
|
||||
}
|
||||
when (entry.name) {
|
||||
"newpipe.db" -> {
|
||||
// Reject duplicate entries — a malicious zip
|
||||
// can put a benign db first and a hostile
|
||||
// second; ZipInputStream walks in order and
|
||||
// would overwrite.
|
||||
if (dbFile != null) {
|
||||
warnings += "duplicate newpipe.db in archive — aborting"
|
||||
return null to null
|
||||
}
|
||||
val out = File(workDir, "newpipe.db")
|
||||
val written = copyBounded(zip, out, MAX_DB_BYTES)
|
||||
if (written < 0L) {
|
||||
warnings += "newpipe.db exceeds ${MAX_DB_BYTES / (1024 * 1024)} MB — aborting"
|
||||
out.delete()
|
||||
return null to null
|
||||
}
|
||||
dbFile = out
|
||||
}
|
||||
"preferences.json" -> {
|
||||
if (prefs != null) {
|
||||
warnings += "duplicate preferences.json in archive — aborting"
|
||||
return null to null
|
||||
}
|
||||
val bytes = readBoundedBytes(zip, MAX_PREFS_BYTES)
|
||||
if (bytes == null) {
|
||||
warnings += "preferences.json exceeds ${MAX_PREFS_BYTES / 1024} KB — skipping"
|
||||
} else {
|
||||
prefs = runCatching {
|
||||
Json.parseToJsonElement(bytes.decodeToString()) as? JsonObject
|
||||
}.getOrNull()
|
||||
if (prefs == null) warnings += "preferences.json present but unparseable"
|
||||
}
|
||||
}
|
||||
// newpipe.settings is the legacy XML form; preferences.json
|
||||
// supersedes it in every modern export. Skip.
|
||||
else -> { /* ignore other entries */ }
|
||||
}
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
} ?: error("Could not open the selected file")
|
||||
if (dbFile == null) warnings += "newpipe.db not found in archive — most data skipped"
|
||||
if (prefs == null) warnings += "preferences.json not found — settings not migrated"
|
||||
return dbFile to prefs
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded copy. Returns bytes-written on success, -1 if `cap` was
|
||||
* exceeded. Used instead of `copyTo` so a 16 GB zip-bomb doesn't
|
||||
* fill the user's cacheDir before we notice.
|
||||
*/
|
||||
private fun copyBounded(src: java.io.InputStream, dst: File, cap: Long): Long {
|
||||
dst.outputStream().use { os ->
|
||||
val buf = ByteArray(64 * 1024)
|
||||
var total = 0L
|
||||
while (true) {
|
||||
val n = src.read(buf)
|
||||
if (n <= 0) break
|
||||
total += n
|
||||
if (total > cap) return -1L
|
||||
os.write(buf, 0, n)
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
|
||||
private fun readBoundedBytes(src: java.io.InputStream, cap: Long): ByteArray? {
|
||||
val baos = java.io.ByteArrayOutputStream()
|
||||
val buf = ByteArray(16 * 1024)
|
||||
var total = 0L
|
||||
while (true) {
|
||||
val n = src.read(buf)
|
||||
if (n <= 0) break
|
||||
total += n
|
||||
if (total > cap) return null
|
||||
baos.write(buf, 0, n)
|
||||
}
|
||||
return baos.toByteArray()
|
||||
}
|
||||
|
||||
private data class SubsResult(val added: Int, val skipped: Int)
|
||||
private fun importSubscriptions(dbFile: File): SubsResult {
|
||||
val store = Subscriptions.get()
|
||||
// Cap input row count too — hostile NewPipe export with a
|
||||
// million rows would still walk the cursor fully without this.
|
||||
val maxRows = 10_000
|
||||
var skipped = 0
|
||||
val staged = mutableListOf<ChannelRef>()
|
||||
openDb(dbFile).use { db ->
|
||||
db.rawQuery(
|
||||
"SELECT url, name, avatar_url, service_id FROM subscriptions LIMIT $maxRows",
|
||||
null,
|
||||
).use { c ->
|
||||
while (c.moveToNext()) {
|
||||
val serviceId = c.getInt(3)
|
||||
if (serviceId != YT_SERVICE_ID) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
val url = c.getString(0) ?: continue
|
||||
if (!isAllowedYtUrl(url)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
val name = c.getString(1) ?: continue
|
||||
val avatar = c.getString(2)
|
||||
staged += ChannelRef(url = url, name = name, avatar = avatar)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Single dedup + single persist regardless of N.
|
||||
val added = store.addAll(staged)
|
||||
return SubsResult(added, skipped)
|
||||
}
|
||||
|
||||
private data class PlResult(val playlists: Int, val items: Int)
|
||||
private fun importPlaylists(dbFile: File): PlResult {
|
||||
val store = Playlists.get()
|
||||
var playlistsAdded = 0
|
||||
var itemsAdded = 0
|
||||
openDb(dbFile).use { db ->
|
||||
val playlistRows = mutableListOf<Pair<Long, String>>()
|
||||
// Hard caps so a malicious export with millions of rows
|
||||
// doesn't walk an unbounded cursor into memory.
|
||||
db.rawQuery("SELECT uid, name FROM playlists LIMIT 256", null).use { c ->
|
||||
while (c.moveToNext()) {
|
||||
val uid = c.getLong(0)
|
||||
val name = c.getString(1) ?: "Untitled"
|
||||
playlistRows += uid to name
|
||||
}
|
||||
}
|
||||
for ((uid, name) in playlistRows) {
|
||||
val items = mutableListOf<PlaylistItem>()
|
||||
db.rawQuery(
|
||||
"""
|
||||
SELECT s.url, s.title, s.thumbnail_url, s.uploader, s.service_id
|
||||
FROM playlist_stream_join j
|
||||
JOIN streams s ON s.uid = j.stream_id
|
||||
WHERE j.playlist_id = ?
|
||||
ORDER BY j.join_index
|
||||
LIMIT 5000
|
||||
""".trimIndent(),
|
||||
arrayOf(uid.toString()),
|
||||
).use { c ->
|
||||
while (c.moveToNext()) {
|
||||
if (c.getInt(4) != YT_SERVICE_ID) continue
|
||||
val streamUrl = c.getString(0) ?: continue
|
||||
if (!isAllowedYtUrl(streamUrl)) continue
|
||||
items += PlaylistItem(
|
||||
streamUrl = streamUrl,
|
||||
title = c.getString(1) ?: "(no title)",
|
||||
thumbnail = c.getString(2),
|
||||
uploader = c.getString(3) ?: "",
|
||||
addedAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (items.isEmpty()) continue
|
||||
// Bulk import: one CAS + one SP write per playlist
|
||||
// instead of (1 create + N addItem) writes. Old shape
|
||||
// produced ~10k SP commits on a 100×100 export, plus
|
||||
// O(N²) work in addItem's per-call linear scan over
|
||||
// every playlist.
|
||||
store.importPlaylist(name, items)
|
||||
playlistsAdded++
|
||||
itemsAdded += items.size
|
||||
}
|
||||
}
|
||||
return PlResult(playlistsAdded, itemsAdded)
|
||||
}
|
||||
|
||||
private data class HistResult(
|
||||
val watchesAdded: Int,
|
||||
val watchesAvailable: Int,
|
||||
val searches: Int,
|
||||
val searchesAvailable: Int,
|
||||
val resumePositions: Int,
|
||||
)
|
||||
|
||||
private fun importHistory(dbFile: File): HistResult {
|
||||
val historyStore = History.get()
|
||||
var watchesSeen = 0
|
||||
var watchesAvailable = 0
|
||||
var searchesSeen = 0
|
||||
var resumePositions = 0
|
||||
var watchesAdded = 0
|
||||
var searchesAdded = 0
|
||||
openDb(dbFile).use { db ->
|
||||
// Search history — feed oldest first so the store ends up with
|
||||
// the most-recent on top after its own dedup + take(MAX).
|
||||
// Stage + bulk-write —:
|
||||
// per-row recordSearch was N SP writes on potentially
|
||||
// 100k+ rows. The SELECT also lacked a LIMIT; added now.
|
||||
val stagedSearches = mutableListOf<String>()
|
||||
db.rawQuery(
|
||||
"SELECT search FROM search_history WHERE service_id=? ORDER BY creation_date ASC LIMIT 50000",
|
||||
arrayOf(YT_SERVICE_ID.toString()),
|
||||
).use { c ->
|
||||
while (c.moveToNext()) {
|
||||
val q = c.getString(0) ?: continue
|
||||
stagedSearches += q
|
||||
searchesSeen++
|
||||
}
|
||||
}
|
||||
searchesAdded = historyStore.recordAllSearches(stagedSearches)
|
||||
|
||||
// Watch history — newest first via stream_history.access_date,
|
||||
// joined to streams for the metadata we need.
|
||||
// recordWatch caps internally; we just stop counting "added" once
|
||||
// we've replayed Straw's MAX rows. (The store reverses to put
|
||||
// most-recent on top — so we feed it oldest-first to match.)
|
||||
db.rawQuery("SELECT COUNT(*) FROM stream_history", null).use { c ->
|
||||
if (c.moveToNext()) watchesAvailable = c.getInt(0)
|
||||
}
|
||||
// Stage rows in memory, then one bulk write — same DoS
|
||||
// mitigation as importSubscriptions. recordWatch did N SP
|
||||
// writes and an O(N) dedup per row.
|
||||
val staged = mutableListOf<WatchHistoryItem>()
|
||||
db.rawQuery(
|
||||
"""
|
||||
SELECT s.url, s.title, s.uploader, s.thumbnail_url, h.access_date, s.service_id
|
||||
FROM stream_history h
|
||||
JOIN streams s ON s.uid = h.stream_id
|
||||
ORDER BY h.access_date ASC
|
||||
LIMIT 50000
|
||||
""".trimIndent(),
|
||||
null,
|
||||
).use { c ->
|
||||
while (c.moveToNext()) {
|
||||
if (c.getInt(5) != YT_SERVICE_ID) continue
|
||||
val url = c.getString(0) ?: continue
|
||||
if (!isAllowedYtUrl(url)) continue
|
||||
val title = c.getString(1) ?: continue
|
||||
val uploader = c.getString(2) ?: ""
|
||||
val thumb = c.getString(3)
|
||||
val videoId = extractYtVideoId(url) ?: continue
|
||||
staged += WatchHistoryItem(
|
||||
url = url,
|
||||
videoId = videoId,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
thumbnail = thumb,
|
||||
watchedAt = c.getLong(4),
|
||||
)
|
||||
watchesSeen++
|
||||
}
|
||||
}
|
||||
watchesAdded = historyStore.recordAllWatches(staged)
|
||||
|
||||
// Resume positions — counted, not stored. Future task hooks into
|
||||
// a ResumePositionsStore.
|
||||
db.rawQuery("SELECT COUNT(*) FROM stream_state", null).use { c ->
|
||||
if (c.moveToNext()) resumePositions = c.getInt(0)
|
||||
}
|
||||
}
|
||||
// recordAllWatches / recordAllSearches return the real
|
||||
// added count (counts fresh videoIds / queries that landed,
|
||||
// ignoring duplicates and pre-saturated-store truncation).
|
||||
// / MED-2 — previous size_after
|
||||
// size_before reported 0 when the store was already at cap
|
||||
// even when 20 fresh imports actually landed.
|
||||
return HistResult(
|
||||
watchesAdded = watchesAdded,
|
||||
watchesAvailable = watchesAvailable.takeIf { it > 0 } ?: watchesSeen,
|
||||
searches = searchesAdded,
|
||||
resumePositions = resumePositions,
|
||||
searchesAvailable = searchesSeen,
|
||||
)
|
||||
}
|
||||
|
||||
private fun importSettings(prefs: JsonObject): Int {
|
||||
val settings = Settings.get()
|
||||
var applied = 0
|
||||
|
||||
// SponsorBlock: master toggle gates the categories. If disabled in
|
||||
// NewPipe, leave Straw's categories alone (they have a non-empty
|
||||
// default). If enabled, sync each category boolean.
|
||||
val sbMaster = prefs.boolOrNull("sponsor_block_enable")
|
||||
if (sbMaster == true) {
|
||||
val targets = mapOf(
|
||||
"sponsor_block_category_sponsor" to SbCategory.Sponsor,
|
||||
"sponsor_block_category_self_promo" to SbCategory.SelfPromo,
|
||||
"sponsor_block_category_intro" to SbCategory.Intro,
|
||||
"sponsor_block_category_outro" to SbCategory.Outro,
|
||||
"sponsor_block_category_interaction" to SbCategory.Interaction,
|
||||
"sponsor_block_category_music" to SbCategory.MusicOfftopic,
|
||||
"sponsor_block_category_filler" to SbCategory.Filler,
|
||||
)
|
||||
val current = settings.sbCategories.value
|
||||
for ((key, cat) in targets) {
|
||||
val want = prefs.boolOrNull(key) ?: continue
|
||||
val have = cat in current
|
||||
// Only count an applied toggle when it actually
|
||||
// changed something. Prior shape counted every
|
||||
// observed key, inflating the import summary to
|
||||
// "12 settings applied" when only 2 changed.
|
||||
if (want != have) {
|
||||
settings.toggle(cat)
|
||||
applied++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default resolution: NewPipe values like "720p60", "1080p", "Best
|
||||
// resolution". Map down to Straw's discrete ceilings.
|
||||
prefs.stringOrNull("default_resolution")?.let { raw ->
|
||||
val r = parseResolution(raw)
|
||||
if (r != null) {
|
||||
settings.setMaxResolution(r)
|
||||
applied++
|
||||
}
|
||||
}
|
||||
|
||||
return applied
|
||||
}
|
||||
|
||||
private fun parseResolution(raw: String): com.sulkta.straw.data.MaxResolution? {
|
||||
val n = Regex("(\\d+)").find(raw)?.groupValues?.get(1)?.toIntOrNull()
|
||||
?: return when (raw.lowercase()) {
|
||||
"best resolution", "best", "highest" -> com.sulkta.straw.data.MaxResolution.Auto
|
||||
else -> null
|
||||
}
|
||||
return when {
|
||||
n >= 1080 -> com.sulkta.straw.data.MaxResolution.P1080
|
||||
n >= 720 -> com.sulkta.straw.data.MaxResolution.P720
|
||||
n >= 480 -> com.sulkta.straw.data.MaxResolution.P480
|
||||
n >= 360 -> com.sulkta.straw.data.MaxResolution.P360
|
||||
else -> com.sulkta.straw.data.MaxResolution.P144
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDb(dbFile: File): SQLiteDatabase =
|
||||
SQLiteDatabase.openDatabase(
|
||||
dbFile.absolutePath,
|
||||
/* factory = */ null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
|
||||
// YouTube URL patterns we need to parse for the videoId column on
|
||||
// WatchHistoryItem. Cover the watch?v= form (canonical), youtu.be
|
||||
// shortlinks, and embed/. Reject anything we can't parse rather than
|
||||
// inventing IDs.
|
||||
private val YT_ID = Regex(
|
||||
"(?:youtu\\.be/|youtube(?:-nocookie)?\\.com/(?:watch\\?(?:.*&)?v=|embed/|v/|shorts/))([A-Za-z0-9_-]{6,15})",
|
||||
)
|
||||
private fun extractYtVideoId(url: String): String? =
|
||||
YT_ID.find(url)?.groupValues?.get(1)
|
||||
|
||||
private fun JsonObject.boolOrNull(key: String): Boolean? =
|
||||
runCatching { this[key]?.jsonPrimitive?.boolean }.getOrNull()
|
||||
|
||||
private fun JsonObject.stringOrNull(key: String): String? =
|
||||
runCatching { this[key]?.jsonPrimitive?.contentOrNull }.getOrNull()
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Pick the playable URLs from a strawcore StreamInfo. Lives outside
|
||||
* VideoDetailViewModel so the queue path can call it too.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.detail
|
||||
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.net.SbSegment
|
||||
|
||||
/**
|
||||
* Extract the YouTube video ID from a watch URL. Handles the common
|
||||
* forms: `youtube.com/watch?v=XXXXXXXXXXX`, `youtu.be/X...`, and
|
||||
* `youtube.com/shorts/X...`. Returns null when nothing matches.
|
||||
*
|
||||
* Centralized here so the autoplay + history + import paths all
|
||||
* resolve videoIds the same way. Duplicates an earlier per-file regex
|
||||
* (`StrawHome.kt:VIDEO_ID_RE`) — that one can fold into this when next
|
||||
* touched.
|
||||
*/
|
||||
private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&#].*)?$")
|
||||
|
||||
fun extractYtVideoId(url: String): String? =
|
||||
VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1)?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* Convert a raw strawcore.StreamInfo into the picked-URL DTO the
|
||||
* MediaController wants. Honors Settings.maxResolution — cap-fit if
|
||||
* possible, otherwise the closest-to-cap fallback (lowest height) so
|
||||
* we don't blow a user's data plan when only above-cap streams exist.
|
||||
*
|
||||
* `segments` is the SponsorBlock list to bake into the resulting
|
||||
* ResolvedPlayback; pass emptyList() when no SB is desired (the queue
|
||||
* path doesn't pre-fetch SB for queued items).
|
||||
*/
|
||||
fun resolveStreamPlayback(
|
||||
info: uniffi.strawcore.StreamInfo,
|
||||
segments: List<SbSegment> = emptyList(),
|
||||
): ResolvedPlayback {
|
||||
val maxRes = Settings.get().maxResolution.value.ceiling
|
||||
fun pickVideo(streams: List<uniffi.strawcore.VideoStreamItem>): String? {
|
||||
if (streams.isEmpty()) return null
|
||||
val capped = streams.filter { it.height <= maxRes }
|
||||
return if (capped.isNotEmpty()) {
|
||||
capped.maxByOrNull { it.bitrate }?.url
|
||||
} else {
|
||||
streams.minByOrNull { it.height }?.url
|
||||
}
|
||||
}
|
||||
return ResolvedPlayback(
|
||||
title = info.title,
|
||||
videoUrl = pickVideo(info.videoOnly),
|
||||
audioUrl = info.audioOnly.maxByOrNull { it.bitrate }?.url,
|
||||
combinedUrl = pickVideo(info.combined),
|
||||
dashMpdUrl = info.dashMpdUrl?.takeIf { it.isNotBlank() },
|
||||
hlsUrl = info.hlsUrl?.takeIf { it.isNotBlank() },
|
||||
segments = segments,
|
||||
)
|
||||
}
|
||||
|
|
@ -5,132 +5,292 @@
|
|||
|
||||
package com.sulkta.straw.feature.detail
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Rational
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Headphones
|
||||
import androidx.compose.material.icons.filled.PictureInPictureAlt
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.sulkta.straw.feature.download.DownloadKind
|
||||
import com.sulkta.straw.feature.download.Downloader
|
||||
import com.sulkta.straw.feature.player.PlayerViewModel
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionParameters
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.dash.DashMediaSource
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.MergingMediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.ui.PlayerView
|
||||
import coil3.compose.AsyncImage
|
||||
import com.sulkta.straw.extractor.NewPipeDownloader
|
||||
import com.sulkta.straw.OverlayChromeColor
|
||||
import com.sulkta.straw.OverlayDimColor
|
||||
import com.sulkta.straw.data.PlaylistItem
|
||||
import com.sulkta.straw.feature.playlist.SaveToPlaylistDialog
|
||||
import com.sulkta.straw.feature.download.DownloadKind
|
||||
import com.sulkta.straw.feature.download.Downloader
|
||||
import com.sulkta.straw.feature.player.LocalStrawController
|
||||
import com.sulkta.straw.feature.player.NowPlaying
|
||||
import com.sulkta.straw.feature.player.VideoThumbnail
|
||||
import com.sulkta.straw.feature.player.setPlayingFrom
|
||||
import com.sulkta.straw.feature.search.StreamItem
|
||||
import com.sulkta.straw.util.LogDump
|
||||
import com.sulkta.straw.data.ChannelRef
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.data.Subscriptions
|
||||
import com.sulkta.straw.util.formatCount
|
||||
import com.sulkta.straw.util.formatViews
|
||||
import com.sulkta.straw.util.stripHtml
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, UnstableApi::class)
|
||||
@Composable
|
||||
fun VideoDetailScreen(
|
||||
streamUrl: String,
|
||||
initialTitle: String,
|
||||
onPlay: () -> Unit,
|
||||
onMinimize: () -> Unit,
|
||||
onOpenChannel: (channelUrl: String, name: String) -> Unit,
|
||||
onOpenVideo: (url: String, title: String) -> Unit,
|
||||
vm: VideoDetailViewModel = viewModel(),
|
||||
) {
|
||||
val state by vm.ui.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val controller = LocalStrawController.current
|
||||
val activity = context as? Activity
|
||||
var showDownloadDialog by remember { mutableStateOf(false) }
|
||||
// Inline-play state. Resets when the user navigates to a different
|
||||
// video (keyed on streamUrl).
|
||||
var inlinePlaying by remember(streamUrl) { mutableStateOf(false) }
|
||||
var showSaveToPlaylistDialog by remember { mutableStateOf(false) }
|
||||
var actionTarget by remember { mutableStateOf<com.sulkta.straw.feature.playlist.VideoActionTarget?>(null) }
|
||||
actionTarget?.let { t ->
|
||||
com.sulkta.straw.feature.playlist.VideoActionsSheet(
|
||||
target = t,
|
||||
onDismiss = { actionTarget = null },
|
||||
)
|
||||
}
|
||||
// Inline-play state resets when navigating to a different video.
|
||||
// Defaults to TRUE when:
|
||||
// * the shared MediaController is already streaming this URL
|
||||
// (back-from-fullscreen — without this the page renders as
|
||||
// "freshly loaded" while audio keeps playing in the
|
||||
// background), or
|
||||
// * the user has Settings → Auto-start playback enabled (cold
|
||||
// open from search / subs / wherever immediately plays).
|
||||
// Off + fresh URL → thumbnail + Play overlay, user taps to start.
|
||||
val autoStart by Settings.get().autoStartPlayback.collectAsState()
|
||||
var inlinePlaying by remember(streamUrl) {
|
||||
mutableStateOf(
|
||||
NowPlaying.current.value?.streamUrl == streamUrl || autoStart,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
|
||||
|
||||
// The Background button (and the fullscreen audio-only toggle)
|
||||
// disable the video track on the shared controller, and that state
|
||||
// sticks. Entering detail = user wants to watch the video — wipe the
|
||||
// override and let DASH pick the highest renderable video again.
|
||||
LaunchedEffect(controller, streamUrl) {
|
||||
controller?.let {
|
||||
it.trackSelectionParameters = TrackSelectionParameters.Builder(context).build()
|
||||
}
|
||||
}
|
||||
|
||||
// Swipe-down to minimize. The drag handle is the inline player surface
|
||||
// at the top of the page; we translate the WHOLE page with it so the
|
||||
// motion reads as "the video is being tucked away" rather than "this
|
||||
// one widget slid."
|
||||
//
|
||||
// Two-state pattern so the drag stays smooth at 120fps:
|
||||
// liveDrag — mutableFloatStateOf updated SYNCHRONOUSLY in
|
||||
// rememberDraggableState's callback. One state write
|
||||
// per pointer event, no coroutine spawn.
|
||||
// releaseAnim — Animatable driven by a single coroutine that
|
||||
// runs only when the finger leaves (spring back
|
||||
// if short, slide off-screen + onMinimize if past
|
||||
// threshold or flung).
|
||||
// graphicsLayer reads whichever is active via the `dragging` flag.
|
||||
// The old single-Animatable / scope.launch-per-pixel pattern
|
||||
// raced coroutines for every drag delta and stuttered on fast
|
||||
// gestures; this doesn't.
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val dismissThresholdPx = with(density) { 140.dp.toPx() }
|
||||
val flingVelocityThreshold = with(density) { 600.dp.toPx() }
|
||||
val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() }
|
||||
// mutableFloatStateOf avoids boxing on every drag delta — the
|
||||
// draggable callback fires 100+ times/s on a fast swipe.
|
||||
var liveDrag by remember { mutableFloatStateOf(0f) }
|
||||
var dragging by remember { mutableStateOf(false) }
|
||||
val releaseAnim = remember { Animatable(0f) }
|
||||
val draggableState = rememberDraggableState { delta ->
|
||||
liveDrag = (liveDrag + delta).coerceAtLeast(0f)
|
||||
}
|
||||
val playerDragModifier = Modifier.draggable(
|
||||
orientation = Orientation.Vertical,
|
||||
state = draggableState,
|
||||
onDragStarted = {
|
||||
releaseAnim.stop()
|
||||
liveDrag = releaseAnim.value
|
||||
dragging = true
|
||||
},
|
||||
onDragStopped = { velocity ->
|
||||
val shouldDismiss =
|
||||
liveDrag > dismissThresholdPx || velocity > flingVelocityThreshold
|
||||
releaseAnim.snapTo(liveDrag)
|
||||
dragging = false
|
||||
if (shouldDismiss) {
|
||||
// Slide the rest of the way off-screen, then pop. The
|
||||
// pop happens AFTER the animation so the user sees the
|
||||
// page leave under their finger instead of a hard cut.
|
||||
releaseAnim.animateTo(
|
||||
screenHeightPx,
|
||||
tween(durationMillis = 220, easing = FastOutLinearInEasing),
|
||||
)
|
||||
onMinimize()
|
||||
} else {
|
||||
releaseAnim.animateTo(
|
||||
0f,
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
),
|
||||
)
|
||||
}
|
||||
liveDrag = 0f
|
||||
},
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
val y = if (dragging) liveDrag else releaseAnim.value
|
||||
translationY = y
|
||||
val p = (y / dismissThresholdPx).coerceIn(0f, 1f)
|
||||
alpha = 1f - p * 0.4f
|
||||
val s = 1f - p * 0.08f
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
}
|
||||
.statusBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
when {
|
||||
state.loading -> Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 64.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 64.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
|
||||
state.error != null -> Text(
|
||||
"error: ${state.error}",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
else -> {
|
||||
val d = state.detail ?: return@Column
|
||||
// Tap the thumbnail to play inline. Fullscreen button (top-right
|
||||
// overlay on the inline player) jumps to the fullscreen Player
|
||||
// screen which has the full toolset.
|
||||
// Guard against vm's activity-scoped staleness — on a
|
||||
// fresh navigation A → B, the shared VM still holds
|
||||
// A's detail/resolved for one composition frame before
|
||||
// vm.load(B)'s reset propagates. Without this gate, the
|
||||
// InlinePlayer's LaunchedEffect would fire with
|
||||
// streamUrl=B but resolved=A's URLs and play A under
|
||||
// B's chrome — symptom is the detail page showing the
|
||||
// new video while the audio is still the old one.
|
||||
if (state.loadedUrl != streamUrl) return@Column
|
||||
// Player surface — edge-to-edge, NewPipe/YouTube style.
|
||||
// Lives outside the 16dp horizontal padding so the
|
||||
// thumbnail fills the screen width with no gutters.
|
||||
if (inlinePlaying) {
|
||||
InlinePlayer(
|
||||
streamUrl = streamUrl,
|
||||
title = d.title,
|
||||
uploader = d.uploader,
|
||||
thumbnail = d.thumbnail,
|
||||
onFullscreen = onPlay,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.Black),
|
||||
.background(Color.Black)
|
||||
.then(playerDragModifier),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { inlinePlaying = true },
|
||||
.background(Color.Black)
|
||||
.clickable { inlinePlaying = true }
|
||||
.then(playerDragModifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
|
|
@ -141,8 +301,8 @@ fun VideoDetailScreen(
|
|||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(Color(0xCC000000)),
|
||||
.clip(CircleShape)
|
||||
.background(OverlayDimColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
|
|
@ -154,27 +314,79 @@ fun VideoDetailScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// ── Title + uploader ─────────────────────────────────────
|
||||
// Everything below the player gets the side gutters
|
||||
// back; player itself remains edge-to-edge.
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Text(
|
||||
text = d.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val uploaderClickable = d.uploaderUrl != null
|
||||
Text(
|
||||
text = d.uploader,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (uploaderClickable) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = if (uploaderClickable) Modifier.clickable {
|
||||
onOpenChannel(d.uploaderUrl!!, d.uploader)
|
||||
} else Modifier,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val uploaderUrl = d.uploaderUrl
|
||||
// Channel row: avatar + name (larger, clickable when we
|
||||
// have a uploaderUrl) + Subscribe / Subscribed toggle.
|
||||
// Matches the YouTube/NewPipe layout below the title.
|
||||
val subs by Subscriptions.get().subs.collectAsStateWithLifecycle()
|
||||
val isSubscribed = uploaderUrl != null && subs.any { it.url == uploaderUrl }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (!d.uploaderAvatar.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = d.uploaderAvatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.then(
|
||||
if (uploaderUrl != null)
|
||||
Modifier.clickable { onOpenChannel(uploaderUrl, d.uploader) }
|
||||
else Modifier
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = d.uploader,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (uploaderUrl != null) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = if (uploaderUrl != null) Modifier
|
||||
.clickable { onOpenChannel(uploaderUrl, d.uploader) }
|
||||
.padding(vertical = 4.dp)
|
||||
else Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
if (d.uploaderSubscriberCount > 0) {
|
||||
Text(
|
||||
text = "${formatCount(d.uploaderSubscriberCount)} subscribers",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (uploaderUrl != null) {
|
||||
val onSubClick = {
|
||||
Subscriptions.get().toggle(
|
||||
ChannelRef(
|
||||
url = uploaderUrl,
|
||||
name = d.uploader,
|
||||
avatar = d.uploaderAvatar,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (isSubscribed) {
|
||||
OutlinedButton(onClick = onSubClick) { Text("Subscribed") }
|
||||
} else {
|
||||
Button(onClick = onSubClick) { Text("Subscribe") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// ── Engagement row: views + RYD likes/dislikes ───────────
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -208,8 +420,113 @@ fun VideoDetailScreen(
|
|||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(onClick = onPlay) { Text("Play") }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val c = controller
|
||||
if (c == null) {
|
||||
Toast.makeText(context, "no player", Toast.LENGTH_SHORT).show()
|
||||
return@OutlinedButton
|
||||
}
|
||||
// Make sure the controller is playing this video
|
||||
// before backing out — otherwise dropping to the
|
||||
// minibar would dismiss into an empty slot.
|
||||
// Optimization: skip the MediaItem build if
|
||||
// the controller is already on this URL.
|
||||
// claim() in setPlayingFrom is the
|
||||
// authoritative race-free guard — this
|
||||
// check is just to avoid the work.
|
||||
if (NowPlaying.current.value?.streamUrl != streamUrl) {
|
||||
val r = state.resolved
|
||||
if (r == null) {
|
||||
Toast.makeText(context, "stream not ready", Toast.LENGTH_SHORT).show()
|
||||
return@OutlinedButton
|
||||
}
|
||||
c.setPlayingFrom(
|
||||
streamUrl = streamUrl,
|
||||
title = d.title,
|
||||
uploader = d.uploader,
|
||||
thumbnail = d.thumbnail,
|
||||
resolved = r,
|
||||
uploaderUrl = d.uploaderUrl,
|
||||
)
|
||||
}
|
||||
// Audio-only: drop video track. Foreground
|
||||
// service keeps the audio going; minibar takes
|
||||
// over once we pop off the detail screen.
|
||||
c.trackSelectionParameters = TrackSelectionParameters.Builder(context)
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
|
||||
.build()
|
||||
if (!c.isPlaying) c.play()
|
||||
onMinimize()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Headphones,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Background")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (activity == null) {
|
||||
Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show()
|
||||
return@OutlinedButton
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show()
|
||||
return@OutlinedButton
|
||||
}
|
||||
// PiP into nothing isn't useful — bail with a
|
||||
// Toast if there's no controller / no resolved
|
||||
// playback to push into it.
|
||||
val c = controller
|
||||
val r = state.resolved
|
||||
if (c == null || r == null) {
|
||||
Toast.makeText(context, "stream not ready", Toast.LENGTH_SHORT).show()
|
||||
return@OutlinedButton
|
||||
}
|
||||
// Optimization: skip the MediaItem build if
|
||||
// the controller is already on this URL.
|
||||
// claim() in setPlayingFrom is the
|
||||
// authoritative race-free guard — this
|
||||
// check is just to avoid the work.
|
||||
if (NowPlaying.current.value?.streamUrl != streamUrl) {
|
||||
c.setPlayingFrom(
|
||||
streamUrl = streamUrl,
|
||||
title = d.title,
|
||||
uploader = d.uploader,
|
||||
thumbnail = d.thumbnail,
|
||||
resolved = r,
|
||||
uploaderUrl = d.uploaderUrl,
|
||||
)
|
||||
}
|
||||
val params = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(16, 9))
|
||||
.build()
|
||||
runCatching { activity.enterPictureInPictureMode(params) }
|
||||
.onSuccess { ok ->
|
||||
if (!ok) Toast.makeText(context, "PiP refused", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
.onFailure { t ->
|
||||
Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PictureInPictureAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Popout")
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
|
|
@ -221,20 +538,21 @@ fun VideoDetailScreen(
|
|||
OutlinedButton(onClick = { showDownloadDialog = true }) {
|
||||
Text("Download")
|
||||
}
|
||||
OutlinedButton(onClick = { showSaveToPlaylistDialog = true }) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// ── Description ──────────────────────────────────────────
|
||||
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
// AUD-MED: cap input length before regex passes — defends
|
||||
// against ANR on multi-MB descriptions.
|
||||
// Cap input length before regex passes — defends against
|
||||
// ANR on multi-MB descriptions.
|
||||
Text(
|
||||
text = stripHtml(d.description.take(20_000)).take(2000),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
// ── Recommended ──────────────────────────────────────────
|
||||
if (d.related.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
|
|
@ -244,12 +562,22 @@ fun VideoDetailScreen(
|
|||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
d.related.take(20).forEach { rel ->
|
||||
RelatedRow(rel) { onOpenVideo(rel.url, rel.title) }
|
||||
androidx.compose.material3.HorizontalDivider()
|
||||
RelatedRow(
|
||||
item = rel,
|
||||
onClick = { onOpenVideo(rel.url, rel.title) },
|
||||
onLongClick = {
|
||||
actionTarget = com.sulkta.straw.feature.playlist.VideoActionTarget(
|
||||
streamUrl = rel.url,
|
||||
title = rel.title,
|
||||
uploader = rel.uploader,
|
||||
thumbnail = rel.thumbnail,
|
||||
)
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
// ── More from <uploader> ─────────────────────────────────
|
||||
if (d.moreFromChannel.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
|
|
@ -260,11 +588,34 @@ fun VideoDetailScreen(
|
|||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
d.moreFromChannel.take(20).forEach { item ->
|
||||
RelatedRow(item) { onOpenVideo(item.url, item.title) }
|
||||
androidx.compose.material3.HorizontalDivider()
|
||||
RelatedRow(
|
||||
item = item,
|
||||
onClick = { onOpenVideo(item.url, item.title) },
|
||||
onLongClick = {
|
||||
actionTarget = com.sulkta.straw.feature.playlist.VideoActionTarget(
|
||||
streamUrl = item.url,
|
||||
title = item.title,
|
||||
uploader = item.uploader,
|
||||
thumbnail = item.thumbnail,
|
||||
)
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
if (showSaveToPlaylistDialog) {
|
||||
SaveToPlaylistDialog(
|
||||
item = PlaylistItem(
|
||||
streamUrl = streamUrl,
|
||||
title = d.title,
|
||||
thumbnail = d.thumbnail,
|
||||
uploader = d.uploader,
|
||||
),
|
||||
onDismiss = { showSaveToPlaylistDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showDownloadDialog) {
|
||||
val info = state.streamInfo
|
||||
AlertDialog(
|
||||
|
|
@ -284,10 +635,7 @@ fun VideoDetailScreen(
|
|||
confirmButton = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
val audio = info?.audioStreams
|
||||
?.filter { it.content?.isNotBlank() == true }
|
||||
?.maxByOrNull { it.bitrate ?: 0 }
|
||||
?.content
|
||||
val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.url
|
||||
if (audio != null) {
|
||||
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
|
||||
val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
|
||||
|
|
@ -298,14 +646,8 @@ fun VideoDetailScreen(
|
|||
showDownloadDialog = false
|
||||
}) { Text("Audio") }
|
||||
Button(onClick = {
|
||||
val video = info?.videoStreams
|
||||
?.filter { it.content?.isNotBlank() == true }
|
||||
?.maxByOrNull { it.bitrate ?: 0 }
|
||||
?.content
|
||||
?: info?.videoOnlyStreams
|
||||
?.filter { it.content?.isNotBlank() == true }
|
||||
?.maxByOrNull { it.bitrate ?: 0 }
|
||||
?.content
|
||||
val video = info?.combined?.maxByOrNull { it.bitrate }?.url
|
||||
?: info?.videoOnly?.maxByOrNull { it.bitrate }?.url
|
||||
if (video != null) {
|
||||
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
|
||||
val msg = if (id > 0) "video queued" else "download refused (bad URL)"
|
||||
|
|
@ -318,37 +660,44 @@ fun VideoDetailScreen(
|
|||
}
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(onClick = { showDownloadDialog = false }) {
|
||||
TextButton(onClick = { showDownloadDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
} // close inner Column (padded body)
|
||||
}
|
||||
}
|
||||
// Leave room at the bottom for the system nav bar so the last
|
||||
// related video doesn't tuck under the gesture pill / 3-button
|
||||
// nav. Compose's `navigationBarsPadding` would push the whole
|
||||
// surface up; we want the scroll to extend past it instead.
|
||||
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun RelatedRow(
|
||||
item: com.sulkta.straw.feature.search.StreamItem,
|
||||
item: StreamItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.thumbnail,
|
||||
contentDescription = null,
|
||||
VideoThumbnail(
|
||||
thumbnail = item.thumbnail,
|
||||
videoUrl = item.url,
|
||||
durationSeconds = item.durationSeconds,
|
||||
modifier = Modifier
|
||||
.width(140.dp)
|
||||
.height(80.dp)
|
||||
.clip(RoundedCornerShape(6.dp)),
|
||||
.height(80.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
|
|
@ -357,125 +706,191 @@ private fun RelatedRow(
|
|||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = buildString {
|
||||
append(item.uploader)
|
||||
if (item.viewCount > 0) {
|
||||
append(" · ")
|
||||
append(formatViews(item.viewCount))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
// Build the metadata line from whatever's available.
|
||||
// channelInfo-sourced items (More from channel) come back
|
||||
// with uploader="" because the channel page doesn't repeat
|
||||
// the uploader name on each row — it's implicit. Skip
|
||||
// empty pieces with the leading-separator dance so we
|
||||
// never end up with " · viewCount" or trailing dots.
|
||||
// Earlier shape was leaving an empty metadata line on
|
||||
// More-from-channel rows.
|
||||
val meta = buildString {
|
||||
if (item.uploader.isNotBlank()) append(item.uploader)
|
||||
if (item.viewCount > 0) {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append(formatViews(item.viewCount))
|
||||
}
|
||||
if (item.uploadDateRelative.isNotBlank()) {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append(item.uploadDateRelative)
|
||||
}
|
||||
}
|
||||
if (meta.isNotEmpty()) {
|
||||
Text(
|
||||
text = meta,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline player embedded in the 16:9 thumbnail box on VideoDetailScreen.
|
||||
* Uses its own ExoPlayer + PlayerView (with the built-in controller for
|
||||
* play/pause/seek). A small fullscreen pill in the top-right hops the user
|
||||
* to the fullscreen PlayerScreen for the full toolset (speed picker, audio-
|
||||
* only, share, PiP, background). Player is released when the composable
|
||||
* leaves composition (navigate back or away from VideoDetail).
|
||||
* Inline player surface inside VideoDetail's 16:9 thumbnail box. Renders
|
||||
* a PlayerView bound to the shared LocalStrawController — the same
|
||||
* player as the fullscreen PlayerScreen and the minibar overlay. The ⛶
|
||||
* pill hops to fullscreen; playback continues unchanged. There is
|
||||
* nothing to release here: the controller is process-wide, and the
|
||||
* PlayerView's surface is detached on dispose via onRelease.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
private fun InlinePlayer(
|
||||
streamUrl: String,
|
||||
title: String,
|
||||
uploader: String,
|
||||
thumbnail: String?,
|
||||
onFullscreen: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val playerVm: PlayerViewModel = viewModel()
|
||||
val state by playerVm.ui.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(streamUrl) { playerVm.resolve(streamUrl) }
|
||||
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true,
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { exoPlayer.release() }
|
||||
}
|
||||
val controller = LocalStrawController.current
|
||||
val vm: VideoDetailViewModel = viewModel()
|
||||
val state by vm.ui.collectAsStateWithLifecycle()
|
||||
|
||||
// Push the resolved stream into the shared controller if it isn't
|
||||
// already playing this URL. We don't kick off a new fetch — the
|
||||
// outer VideoDetailScreen already called vm.load(streamUrl).
|
||||
//
|
||||
// retryVersion lets the user manually re-fire setPlayingFrom after
|
||||
// a playback error. Without it, the screen used to lock into the
|
||||
// thumbnail+spinner branch once NowPlaying.clear() fired from
|
||||
// onPlayerError.
|
||||
val resolved = state.resolved
|
||||
LaunchedEffect(resolved) {
|
||||
var retryVersion by remember(streamUrl) { mutableIntStateOf(0) }
|
||||
LaunchedEffect(controller, resolved, streamUrl, retryVersion) {
|
||||
val c = controller ?: return@LaunchedEffect
|
||||
val r = resolved ?: return@LaunchedEffect
|
||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
.setUserAgent(NewPipeDownloader.USER_AGENT)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
val source = when {
|
||||
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.dashMpdUrl))
|
||||
r.hlsUrl != null -> HlsMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.hlsUrl))
|
||||
r.combinedUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.combinedUrl))
|
||||
r.videoUrl != null && r.audioUrl != null -> {
|
||||
val v = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.videoUrl))
|
||||
val a = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.audioUrl))
|
||||
MergingMediaSource(v, a)
|
||||
}
|
||||
r.videoUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.videoUrl))
|
||||
else -> null
|
||||
}
|
||||
if (source != null) {
|
||||
exoPlayer.setMediaSource(source)
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.playWhenReady = true
|
||||
}
|
||||
// Optimization, not safety. claim() guards the race.
|
||||
if (NowPlaying.current.value?.streamUrl == streamUrl) return@LaunchedEffect
|
||||
c.setPlayingFrom(
|
||||
streamUrl = streamUrl,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
thumbnail = thumbnail,
|
||||
resolved = r,
|
||||
uploaderUrl = state.detail?.uploaderUrl,
|
||||
)
|
||||
}
|
||||
|
||||
var playbackError by remember { mutableStateOf<String?>(null) }
|
||||
DisposableEffect(controller) {
|
||||
val c = controller
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
// Scrub the message — Media3's HttpDataSource exceptions
|
||||
// include the full signed URL in.message.
|
||||
val raw = error.message ?: "(no message)"
|
||||
playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
|
||||
// Clear NowPlaying so the minibar drops the dead
|
||||
// session.
|
||||
NowPlaying.clear()
|
||||
}
|
||||
}
|
||||
c?.addListener(listener)
|
||||
onDispose { c?.removeListener(listener) }
|
||||
}
|
||||
|
||||
// Track whether the shared controller has actually swapped over to
|
||||
// THIS video's stream. Until it does (the brief window between
|
||||
// streamInfo resolving and setPlayingFrom + setMediaItem landing),
|
||||
// binding PlayerView to the controller would render the PREVIOUS
|
||||
// video's frame under the new detail page — exactly the "new page,
|
||||
// old video" bug.
|
||||
val nowPlaying by NowPlaying.current.collectAsStateWithLifecycle()
|
||||
val controllerOnThisVideo = nowPlaying?.streamUrl == streamUrl
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
when {
|
||||
state.loading -> CircularProgressIndicator(color = Color.White)
|
||||
controller == null || state.loading -> CircularProgressIndicator(color = Color.White)
|
||||
state.error != null -> Text(
|
||||
"playback error: ${state.error}",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
playbackError != null -> Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"playback error: $playbackError",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = {
|
||||
// Clear the error AND nudge the LaunchedEffect to
|
||||
// re-attempt setPlayingFrom.
|
||||
// without this the screen used to lock on the
|
||||
// error forever after NowPlaying.clear().
|
||||
playbackError = null
|
||||
retryVersion += 1
|
||||
}) { Text("Retry") }
|
||||
}
|
||||
resolved?.isPlayable != true -> Text(
|
||||
"no playable stream",
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
// Stream resolved for THIS URL but the controller hasn't
|
||||
// actually swapped media items yet — show the thumbnail
|
||||
// with a spinner. Without this, the PlayerView below would
|
||||
// bind to the controller and render the OUTGOING video's
|
||||
// last frame while the new detail page chrome shows the
|
||||
// new title/description. Bug reported 2026-05-26.
|
||||
!controllerOnThisVideo -> {
|
||||
if (!thumbnail.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = thumbnail,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
else -> {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
player = exoPlayer
|
||||
player = controller
|
||||
useController = true
|
||||
// Same surface-handoff polish as the
|
||||
// fullscreen PlayerView — hold the last
|
||||
// frame on dispose so the inline ↔
|
||||
// fullscreen transition doesn't flash
|
||||
// black between detach + reattach.
|
||||
setKeepContentOnPlayerReset(true)
|
||||
// Don't let the device timeout while the
|
||||
// inline player is on-screen with the
|
||||
// user reading the description. Detaches
|
||||
// automatically when this view goes away.
|
||||
keepScreenOn = true
|
||||
}
|
||||
},
|
||||
update = { it.player = controller },
|
||||
onRelease = { it.player = null },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
// Top-right fullscreen pill — hops to the fullscreen
|
||||
// PlayerScreen which has speed/audio-only/share/PiP/background.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color(0xCC222222))
|
||||
.background(OverlayChromeColor)
|
||||
.clickable(onClick = onFullscreen),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
|
|
@ -485,4 +900,3 @@ private fun InlinePlayer(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* One VM per video URL — drives VideoDetail, the fullscreen Player, and
|
||||
* the inline player on detail (all live in the same activity-scoped VM
|
||||
* store, so `viewModel()` from each composable returns this instance).
|
||||
*
|
||||
* `load(url)` fetches strawcore.streamInfo once, derives both `detail`
|
||||
* (title, uploader, view count, RYD, related, more-from-channel) and
|
||||
* `resolved` (the picked stream URLs the player needs), and records the
|
||||
* video to watch history. Subsequent `load(url)` calls for the same URL
|
||||
* are a no-op so the spinner only fires on a real navigation change.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.detail
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
|
@ -12,155 +21,317 @@ import com.sulkta.straw.data.Settings
|
|||
import com.sulkta.straw.data.WatchHistoryItem
|
||||
import com.sulkta.straw.net.RydClient
|
||||
import com.sulkta.straw.net.RydVotes
|
||||
import com.sulkta.straw.net.SbSegment
|
||||
import com.sulkta.straw.net.SponsorBlockClient
|
||||
import com.sulkta.straw.util.bestThumbnail
|
||||
import com.sulkta.straw.feature.search.StreamItem
|
||||
import com.sulkta.straw.util.isAllowedYtUrl
|
||||
import com.sulkta.straw.util.runCatchingCancellable
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.ServiceList
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
|
||||
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
|
||||
data class VideoDetail(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val uploader: String,
|
||||
val uploaderUrl: String?,
|
||||
/**
|
||||
* Uploader's channel avatar (square-ish thumbnail). Populated
|
||||
* from the same strawcore.channelInfo call that fills
|
||||
* `moreFromChannel`; null until that call resolves, or when the
|
||||
* uploaderUrl is missing / fails the allowlist. Renders as a
|
||||
* small circle next to the channel name on VideoDetail.
|
||||
*/
|
||||
val uploaderAvatar: String? = null,
|
||||
val uploaderSubscriberCount: Long = -1,
|
||||
val viewCount: Long,
|
||||
val description: String,
|
||||
val thumbnail: String?,
|
||||
val ryd: RydVotes? = null,
|
||||
val sbSegmentCount: Int = 0,
|
||||
val related: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(),
|
||||
/** Other videos from the same channel — separate from related (which is YT's
|
||||
* algo). Anchored to the uploader the user chose; matches the sub-feed ethos. */
|
||||
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(),
|
||||
val related: List<StreamItem> = emptyList(),
|
||||
/**
|
||||
* Other videos from the same channel — separate from `related`
|
||||
* (which is YT's algo). Anchored to the uploader the user chose;
|
||||
* matches the sub-feed ethos.
|
||||
*/
|
||||
val moreFromChannel: List<StreamItem> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Stream URLs picked from `streamInfo` for the player. The picker prefers
|
||||
* DASH (whole-quality + adaptive) → HLS → combined progressive → merged
|
||||
* video+audio progressive → video-only. Carries SB segments for the
|
||||
* activity-level skip loop.
|
||||
*/
|
||||
data class ResolvedPlayback(
|
||||
val title: String,
|
||||
val videoUrl: String?,
|
||||
val audioUrl: String?,
|
||||
val combinedUrl: String?,
|
||||
val dashMpdUrl: String?,
|
||||
val hlsUrl: String?,
|
||||
val segments: List<SbSegment> = emptyList(),
|
||||
) {
|
||||
val isPlayable: Boolean
|
||||
get() = !combinedUrl.isNullOrBlank() || !videoUrl.isNullOrBlank() ||
|
||||
!dashMpdUrl.isNullOrBlank() || !hlsUrl.isNullOrBlank()
|
||||
}
|
||||
|
||||
data class VideoDetailUiState(
|
||||
val loading: Boolean = true,
|
||||
val detail: VideoDetail? = null,
|
||||
val resolved: ResolvedPlayback? = null,
|
||||
val error: String? = null,
|
||||
// Stored on success for handoff to player. Not in UI.
|
||||
val streamInfo: StreamInfo? = null,
|
||||
/** Raw extractor result — kept around for the Download dialog. */
|
||||
val streamInfo: uniffi.strawcore.StreamInfo? = null,
|
||||
/**
|
||||
* Tracks which URL the current `detail`/`resolved` belong to.
|
||||
* vm is activity-scoped, so a fresh navigation to detail B sees
|
||||
* the PREVIOUS video's state for one composition frame before
|
||||
* vm.load(B) clears it. Without this field, the InlinePlayer's
|
||||
* setPlayingFrom would fire with streamUrl=B but resolved=A's
|
||||
* playback URLs — claiming NowPlaying with B's streamUrl but
|
||||
* playing A's video under it. audit.
|
||||
*/
|
||||
val loadedUrl: String? = null,
|
||||
)
|
||||
|
||||
class VideoDetailViewModel : ViewModel() {
|
||||
private val _ui = MutableStateFlow(VideoDetailUiState())
|
||||
val ui: StateFlow<VideoDetailUiState> = _ui.asStateFlow()
|
||||
|
||||
private var loadedUrl: String? = null
|
||||
// Track the active load coroutine so a rapid tap to a different video
|
||||
// cancels the prior fetch; otherwise a slow-to-finish older load
|
||||
// overwrites the newer state and the player ends up streaming A while
|
||||
// the detail UI shows B.
|
||||
private var inFlight: Job? = null
|
||||
|
||||
fun load(streamUrl: String) {
|
||||
// viewModel() is Activity-scoped, so the same VM is reused across
|
||||
// navigations. Compare the requested URL with what we last loaded.
|
||||
if (loadedUrl == streamUrl && _ui.value.detail != null) return
|
||||
loadedUrl = streamUrl
|
||||
_ui.value = VideoDetailUiState(loading = true)
|
||||
viewModelScope.launch {
|
||||
// viewModel() is activity-scoped, so the same VM is reused across
|
||||
// navigations. Skip the refetch if the requested URL already has
|
||||
// a resolved state. Snapshot _ui once so the two reads agree.
|
||||
val snap = _ui.value
|
||||
if (snap.loadedUrl == streamUrl && snap.detail != null) return
|
||||
// Same YT-host gate as ChannelViewModel — covers the case
|
||||
// where a tap on a poisoned related-card lands here.
|
||||
// cancel any
|
||||
// in-flight load on rejection too — otherwise the
|
||||
// late-arriving prior-job's fence still PASSES (loadedUrl
|
||||
// wasn't moved) and clobbers the "Unsupported URL" error
|
||||
// banner.: also set loadedUrl on this
|
||||
// path so the gate reads coherently for any caller that
|
||||
// checks _ui.value.loadedUrl on the rejected path.
|
||||
if (!isAllowedYtUrl(streamUrl)) {
|
||||
inFlight?.cancel()
|
||||
inFlight = null
|
||||
_ui.update {
|
||||
VideoDetailUiState(
|
||||
loading = false,
|
||||
error = "Unsupported URL",
|
||||
loadedUrl = streamUrl,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
inFlight?.cancel()
|
||||
_ui.update { VideoDetailUiState(loading = true, loadedUrl = streamUrl) }
|
||||
inFlight = viewModelScope.launch {
|
||||
try {
|
||||
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
|
||||
// strawcore.streamInfo is suspend on tokio; no Dispatchers.IO wrap.
|
||||
val info = uniffi.strawcore.streamInfo(streamUrl)
|
||||
val videoId = info.id
|
||||
val thumb = bestThumbnail(info.thumbnails)
|
||||
val title = info.name ?: "(no title)"
|
||||
val uploader = info.uploaderName ?: ""
|
||||
val thumb = info.thumbnail
|
||||
val title = info.title.ifBlank { "(no title)" }
|
||||
val uploader = info.uploader
|
||||
|
||||
runCatching {
|
||||
History.get().recordWatch(
|
||||
WatchHistoryItem(
|
||||
url = streamUrl,
|
||||
videoId = videoId,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
thumbnail = thumb,
|
||||
watchedAt = 0L,
|
||||
),
|
||||
// Move SP write off the main coroutine — recordWatch
|
||||
// JSON-encodes the watch list (up to 50 entries) +
|
||||
// sp.edit.apply. Small but synchronous;
|
||||
// audit Q9. Only record when the resolved URL passes
|
||||
// the YT allowlist — otherwise extractor-emitted
|
||||
// non-YT URLs (poisoned related/moreFromChannel) end
|
||||
// up in Recent Watches and survive process death.
|
||||
if (isAllowedYtUrl(streamUrl)) {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatchingCancellable {
|
||||
History.get().recordWatch(
|
||||
WatchHistoryItem(
|
||||
url = streamUrl,
|
||||
videoId = videoId,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
thumbnail = thumb,
|
||||
watchedAt = 0L,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RYD + SponsorBlock in parallel — both are independent
|
||||
// network round-trips that block the detail UI. Running
|
||||
// them sequentially via two withContext blocks left the
|
||||
// slower one fully serialized behind the faster one
|
||||
// (~200-500ms wasted per video open). async{}.await()
|
||||
// on Dispatchers.IO closes that gap.
|
||||
val sbCats = Settings.get().sbCategories.value.map { it.key }
|
||||
val (ryd, segments) = coroutineScope {
|
||||
val rydDeferred = async(Dispatchers.IO) {
|
||||
runCatchingCancellable { RydClient.fetch(videoId) }.getOrNull()
|
||||
}
|
||||
val sbDeferred = async(Dispatchers.IO) {
|
||||
if (sbCats.isEmpty()) emptyList()
|
||||
else runCatchingCancellable {
|
||||
SponsorBlockClient.fetch(videoId, sbCats)
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
rydDeferred.await() to sbDeferred.await()
|
||||
}
|
||||
|
||||
val related = info.related.map { r ->
|
||||
StreamItem(
|
||||
url = r.url,
|
||||
title = r.title.ifBlank { "(no title)" },
|
||||
uploader = r.uploader,
|
||||
uploaderUrl = r.uploaderUrl,
|
||||
thumbnail = r.thumbnail,
|
||||
durationSeconds = r.durationSeconds,
|
||||
viewCount = r.viewCount,
|
||||
uploadDateRelative = r.uploadDateRelative,
|
||||
)
|
||||
}
|
||||
|
||||
val ryd = withContext(Dispatchers.IO) {
|
||||
runCatching { RydClient.fetch(videoId) }.getOrNull()
|
||||
}
|
||||
val sbCats = Settings.get().sbCategories.value.map { it.key }
|
||||
val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) {
|
||||
runCatching { SponsorBlockClient.fetch(videoId, sbCats).size }.getOrDefault(0)
|
||||
}
|
||||
val related = info.relatedItems
|
||||
?.filterIsInstance<StreamInfoItem>()
|
||||
?.map { it ->
|
||||
com.sulkta.straw.feature.search.StreamItem(
|
||||
url = it.url,
|
||||
title = it.name ?: "(no title)",
|
||||
uploader = it.uploaderName ?: "",
|
||||
uploaderUrl = it.uploaderUrl,
|
||||
thumbnail = bestThumbnail(it.thumbnails),
|
||||
durationSeconds = it.duration,
|
||||
viewCount = it.viewCount,
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
// More from this channel — anchored to the uploader the user
|
||||
// already chose. Best-effort: empty if the fetch fails so the
|
||||
// detail screen still renders. Filters out the current video.
|
||||
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> =
|
||||
if (info.uploaderUrl.isNullOrBlank()) emptyList()
|
||||
else withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
|
||||
val ch = ChannelInfo.getInfo(service, info.uploaderUrl)
|
||||
val videosTab = ch.tabs.firstOrNull {
|
||||
it.contentFilters.contains(ChannelTabs.VIDEOS)
|
||||
} ?: ch.tabs.firstOrNull()
|
||||
if (videosTab == null) emptyList()
|
||||
else ChannelTabInfo.getInfo(service, videosTab)
|
||||
.relatedItems
|
||||
.filterIsInstance<StreamInfoItem>()
|
||||
// More from this channel via strawcore.channelInfo — one
|
||||
// Rust round-trip returns the channel's Videos tab pre-mapped.
|
||||
// Gate the auto-fetch behind the same YT-host allowlist
|
||||
// we apply to imports: a poisoned uploaderUrl from the
|
||||
// extractor would otherwise trigger an arbitrary-host
|
||||
// network call.
|
||||
//
|
||||
// validate once and persist the
|
||||
// SAFE value into VideoDetail.uploaderUrl so downstream
|
||||
// consumers (NowPlaying → PlaybackService autoplay,
|
||||
// queue, etc.) inherit the validated string instead
|
||||
// of the raw extractor value.
|
||||
val rawUploaderUrl = info.uploaderUrl
|
||||
val uploaderUrl = if (!rawUploaderUrl.isNullOrBlank() && isAllowedYtUrl(rawUploaderUrl)) {
|
||||
rawUploaderUrl
|
||||
} else null
|
||||
data class ChannelExtras(
|
||||
val avatar: String?,
|
||||
val subscriberCount: Long,
|
||||
val videos: List<StreamItem>,
|
||||
)
|
||||
val channelExtras: ChannelExtras =
|
||||
if (uploaderUrl == null) {
|
||||
ChannelExtras(null, -1, emptyList())
|
||||
} else runCatchingCancellable {
|
||||
val ch = uniffi.strawcore.channelInfo(uploaderUrl)
|
||||
// Opportunistic avatar refresh: if the user is
|
||||
// subscribed and our stored avatar is stale or
|
||||
// missing, push the fresh one back to the store
|
||||
// so the subs feed picks it up too.
|
||||
//
|
||||
// Validate the scheme before persisting — the
|
||||
// extractor surfaces the URL string verbatim
|
||||
// and a poisoned channel page could ship
|
||||
// `data:image/svg+xml,<svg>...<script>` or
|
||||
// `javascript:`.
|
||||
val fresh = ch.avatar
|
||||
val safeFresh = if (!fresh.isNullOrBlank() &&
|
||||
(fresh.startsWith("https://") || fresh.startsWith("http://"))) {
|
||||
fresh
|
||||
} else null
|
||||
if (safeFresh != null) {
|
||||
runCatchingCancellable {
|
||||
com.sulkta.straw.data.Subscriptions
|
||||
.get().updateAvatar(uploaderUrl, safeFresh)
|
||||
}
|
||||
}
|
||||
ChannelExtras(
|
||||
avatar = safeFresh,
|
||||
subscriberCount = ch.subscriberCount,
|
||||
videos = ch.videos
|
||||
.filter { it.url != streamUrl }
|
||||
.take(20)
|
||||
.map { si ->
|
||||
com.sulkta.straw.feature.search.StreamItem(
|
||||
url = si.url,
|
||||
title = si.name ?: "(no title)",
|
||||
uploader = si.uploaderName ?: uploader,
|
||||
uploaderUrl = si.uploaderUrl ?: info.uploaderUrl,
|
||||
thumbnail = bestThumbnail(si.thumbnails),
|
||||
durationSeconds = si.duration,
|
||||
viewCount = si.viewCount,
|
||||
.map { v ->
|
||||
StreamItem(
|
||||
url = v.url,
|
||||
title = v.title.ifBlank { "(no title)" },
|
||||
uploader = v.uploader.ifBlank { uploader },
|
||||
uploaderUrl = v.uploaderUrl ?: uploaderUrl,
|
||||
thumbnail = v.thumbnail,
|
||||
durationSeconds = v.durationSeconds,
|
||||
viewCount = v.viewCount,
|
||||
uploadDateRelative = v.uploadDateRelative,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
},
|
||||
)
|
||||
}.getOrDefault(ChannelExtras(null, -1, emptyList()))
|
||||
val moreFromChannel = channelExtras.videos
|
||||
|
||||
_ui.value = VideoDetailUiState(
|
||||
loading = false,
|
||||
detail = VideoDetail(
|
||||
id = videoId,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
uploaderUrl = info.uploaderUrl,
|
||||
viewCount = info.viewCount,
|
||||
description = info.description?.content ?: "",
|
||||
thumbnail = thumb,
|
||||
ryd = ryd,
|
||||
sbSegmentCount = sbCount,
|
||||
related = related,
|
||||
moreFromChannel = moreFromChannel,
|
||||
),
|
||||
streamInfo = info,
|
||||
)
|
||||
val resolved = resolvePlayback(info, segments)
|
||||
|
||||
// Fence the terminal write against late-arriving older
|
||||
// loads: if a subsequent load(B) cancelled this one but
|
||||
// we resolved past the suspension point, drop our
|
||||
// result rather than clobber B's state.
|
||||
// : single source of
|
||||
// truth — read loadedUrl from _ui rather than a
|
||||
// shadowing field.
|
||||
if (_ui.value.loadedUrl != streamUrl) return@launch
|
||||
_ui.update {
|
||||
VideoDetailUiState(
|
||||
loading = false,
|
||||
detail = VideoDetail(
|
||||
id = videoId,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
// Use the allowlist-validated value, not
|
||||
// the raw extractor field.
|
||||
uploaderUrl = uploaderUrl,
|
||||
uploaderAvatar = channelExtras.avatar,
|
||||
uploaderSubscriberCount = channelExtras.subscriberCount,
|
||||
viewCount = info.viewCount,
|
||||
description = info.description,
|
||||
thumbnail = thumb,
|
||||
ryd = ryd,
|
||||
sbSegmentCount = segments.size,
|
||||
related = related,
|
||||
moreFromChannel = moreFromChannel,
|
||||
),
|
||||
resolved = resolved,
|
||||
streamInfo = info,
|
||||
loadedUrl = streamUrl,
|
||||
)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
_ui.value = VideoDetailUiState(
|
||||
loading = false,
|
||||
error = t.message ?: t.javaClass.simpleName,
|
||||
)
|
||||
if (t is CancellationException) throw t
|
||||
if (_ui.value.loadedUrl != streamUrl) return@launch
|
||||
_ui.update {
|
||||
VideoDetailUiState(
|
||||
loading = false,
|
||||
error = com.sulkta.straw.util.LogDump.scrubLine(
|
||||
t.message ?: t.javaClass.simpleName,
|
||||
),
|
||||
loadedUrl = streamUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePlayback(
|
||||
info: uniffi.strawcore.StreamInfo,
|
||||
segments: List<SbSegment>,
|
||||
): ResolvedPlayback = resolveStreamPlayback(info, segments)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,18 @@
|
|||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Phase R: minimal download via Android's DownloadManager. Saves to the
|
||||
* Minimal download via Android's DownloadManager. Saves to the
|
||||
* app-private external files dir so we don't need WRITE_EXTERNAL_STORAGE
|
||||
* on older Android. The user can pull files out via a file manager
|
||||
* (under Android/data/com.sulkta.straw.debug/files/...).
|
||||
*
|
||||
* Audit fixes (2026-05-24 pass #2):
|
||||
* HIGH-4: scheme + host validation on the URL before handing it to
|
||||
* DownloadManager — extractor output is not trusted root-of-truth.
|
||||
* HIGH-5: harder filename sanitization — control chars, bidi overrides,
|
||||
* leading dots, trailing whitespace.
|
||||
* MED-6: catch IllegalArgumentException from enqueue so a malformed URI
|
||||
* doesn't crash the click handler.
|
||||
* Hardening:
|
||||
* - scheme + host validation on the URL before enqueueing (extractor
|
||||
* output is not trusted root-of-truth)
|
||||
* - filename sanitization for control chars, bidi overrides, leading
|
||||
* dots, and trailing whitespace
|
||||
* - catches IllegalArgumentException from enqueue so a malformed URI
|
||||
* doesn't crash the click handler
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.download
|
||||
|
|
@ -51,11 +51,27 @@ object Downloader {
|
|||
val filename = "$safeTitle${kind.ext}"
|
||||
val dm = ctx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
// SECURITY: pre-signed googlevideo URLs leak to anything reading
|
||||
// DownloadManager state (system notification stack, downloads UI,
|
||||
// apps with ACCESS_DOWNLOAD_MANAGER). We can't hide the URL from
|
||||
// DM itself without re-implementing the download, but we can hide
|
||||
// it from every surface DM forwards to:
|
||||
// setNotificationVisibility(HIDDEN) — no system notification
|
||||
// surfaces the URL via tap-to-open / accessibility scrapers.
|
||||
// setVisibleInDownloadsUi(false) — the Downloads system app
|
||||
// won't list this entry, so a user opening Files / Downloads
|
||||
// can't long-press → details → see the URL.
|
||||
// Our own DownloadsScreen reads progress out of DM via the ID
|
||||
// returned below, so user-facing UX is unaffected.
|
||||
val req = runCatching {
|
||||
DownloadManager.Request(Uri.parse(url))
|
||||
.setTitle(title)
|
||||
// Sanitized title — bidi-overrides and control chars
|
||||
// in extractor output would otherwise render in
|
||||
// DownloadsScreen's row title.
|
||||
.setTitle(safeTitle)
|
||||
.setDescription("Straw — ${kind.name.lowercase()}")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
||||
.setVisibleInDownloadsUi(false)
|
||||
.setAllowedOverMetered(true)
|
||||
.setAllowedOverRoaming(true)
|
||||
.setDestinationInExternalFilesDir(
|
||||
|
|
@ -88,8 +104,9 @@ object Downloader {
|
|||
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
|
||||
if (!uri.scheme.equals("https", ignoreCase = true)) return false
|
||||
val host = uri.host?.lowercase() ?: return false
|
||||
return host.endsWith(".googlevideo.com") ||
|
||||
host.endsWith(".youtube.com") ||
|
||||
host == "youtube.com"
|
||||
// strawcore returns video/audio stream URLs from googlevideo CDN
|
||||
// exclusively — youtube.com URLs aren't direct streams and have
|
||||
// no business going to DownloadManager.
|
||||
return host.endsWith(".googlevideo.com")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Downloads tab — lists everything Phase R's Downloader handed off to
|
||||
* Android's DownloadManager. Reads live from DownloadManager.query()
|
||||
* keyed by package owner, so we naturally show only this app's queue.
|
||||
*
|
||||
* Row shows: title, kind (audio / video), state (running / completed /
|
||||
* failed), and progress / size. Tap a completed row → ACTION_VIEW
|
||||
* intent to whatever player the user has registered. × removes the
|
||||
* entry from the queue (and the file, per DownloadManager.remove
|
||||
* semantics).
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.download
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sulkta.straw.util.rememberBottomContentPadding
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class DownloadRow(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val localUri: String?,
|
||||
val mediaType: String?,
|
||||
val status: Int,
|
||||
val reason: Int,
|
||||
val bytesSoFar: Long,
|
||||
val totalBytes: Long,
|
||||
) {
|
||||
val progressFraction: Float?
|
||||
get() = if (totalBytes > 0) (bytesSoFar.toFloat() / totalBytes).coerceIn(0f, 1f) else null
|
||||
|
||||
val statusLabel: String
|
||||
get() = when (status) {
|
||||
DownloadManager.STATUS_RUNNING -> "downloading"
|
||||
DownloadManager.STATUS_PENDING -> "pending"
|
||||
DownloadManager.STATUS_PAUSED -> "paused"
|
||||
DownloadManager.STATUS_SUCCESSFUL -> "done"
|
||||
DownloadManager.STATUS_FAILED -> "failed"
|
||||
else -> "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DownloadsScreen() {
|
||||
val context = LocalContext.current
|
||||
var rows by remember { mutableStateOf<List<DownloadRow>>(emptyList()) }
|
||||
|
||||
// DownloadManager doesn't broadcast progress, so we poll while the
|
||||
// screen is visible. Fast cadence (1s) when something is actively
|
||||
// running, slow cadence (5s) when everything is settled — no
|
||||
// animations to update.
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
// DownloadManager.query() is a ContentResolver IPC + a
|
||||
// SQLite cursor walk — disk I/O on the main coroutine
|
||||
// visibly stutters on devices with hundreds of historical
|
||||
// downloads.
|
||||
val fresh = withContext(Dispatchers.IO) { queryDownloads(context) }
|
||||
rows = fresh
|
||||
val active = fresh.any {
|
||||
it.status == DownloadManager.STATUS_RUNNING ||
|
||||
it.status == DownloadManager.STATUS_PENDING
|
||||
}
|
||||
delay(if (active) 1000 else 5000)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
"Downloads",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
"${rows.size} item${if (rows.size == 1) "" else "s"} · saved to app private storage",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (rows.isEmpty()) {
|
||||
Text(
|
||||
"Nothing here yet. Tap Download on any video.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(contentPadding = rememberBottomContentPadding()) {
|
||||
items(rows, key = { it.id }) { row ->
|
||||
DownloadRowView(row, context, onRemove = {
|
||||
runCatching {
|
||||
(context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager)
|
||||
.remove(row.id)
|
||||
}
|
||||
rows = rows.filterNot { it.id == row.id }
|
||||
})
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadRowView(
|
||||
row: DownloadRow,
|
||||
context: Context,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val openable = row.status == DownloadManager.STATUS_SUCCESSFUL && !row.localUri.isNullOrBlank()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = openable) {
|
||||
row.localUri?.let { uri ->
|
||||
// DownloadManager returns a file:// URI for the
|
||||
// setDestinationInExternalFilesDir target. Passing
|
||||
// that across an app boundary throws
|
||||
// FileUriExposedException on every API >= 24 since
|
||||
// minSdk 24. Route through FileProvider so the
|
||||
// receiver gets a grantable content:// URI instead.
|
||||
val shareUri = runCatching {
|
||||
val src = Uri.parse(uri)
|
||||
val path = src.path
|
||||
if (src.scheme == "file" && path != null) {
|
||||
androidx.core.content.FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
java.io.File(path),
|
||||
)
|
||||
} else {
|
||||
src
|
||||
}
|
||||
}.getOrNull() ?: return@let
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(shareUri, row.mediaType ?: "*/*")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
runCatching { context.startActivity(intent) }
|
||||
}
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 56.dp, height = 56.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(if (row.mediaType?.startsWith("audio") == true) "🎵" else "🎬")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
row.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
buildString {
|
||||
append(row.statusLabel)
|
||||
if (row.totalBytes > 0) {
|
||||
append(" · ")
|
||||
append(formatBytes(row.bytesSoFar))
|
||||
append(" / ")
|
||||
append(formatBytes(row.totalBytes))
|
||||
} else if (row.bytesSoFar > 0) {
|
||||
append(" · ")
|
||||
append(formatBytes(row.bytesSoFar))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
row.progressFraction?.takeIf { row.status != DownloadManager.STATUS_SUCCESSFUL }
|
||||
?.let { p ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { p },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onRemove) { Text("×") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryDownloads(context: Context): List<DownloadRow> {
|
||||
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
|
||||
?: return emptyList()
|
||||
val query = DownloadManager.Query()
|
||||
val out = mutableListOf<DownloadRow>()
|
||||
runCatching { dm.query(query) }.getOrNull()?.use { c ->
|
||||
val idIdx = c.getColumnIndex(DownloadManager.COLUMN_ID)
|
||||
val titleIdx = c.getColumnIndex(DownloadManager.COLUMN_TITLE)
|
||||
val uriIdx = c.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
|
||||
val mimeIdx = c.getColumnIndex(DownloadManager.COLUMN_MEDIA_TYPE)
|
||||
val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||
val soFarIdx = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
val totalIdx = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
while (c.moveToNext()) {
|
||||
out += DownloadRow(
|
||||
id = c.getLong(idIdx),
|
||||
title = c.getString(titleIdx) ?: "(no title)",
|
||||
localUri = c.getString(uriIdx),
|
||||
mediaType = c.getString(mimeIdx),
|
||||
status = c.getInt(statusIdx),
|
||||
reason = c.getInt(reasonIdx),
|
||||
bytesSoFar = c.getLong(soFarIdx),
|
||||
totalBytes = c.getLong(totalIdx),
|
||||
)
|
||||
}
|
||||
}
|
||||
return out.sortedByDescending { it.id }
|
||||
}
|
||||
|
||||
private fun formatBytes(b: Long): String = when {
|
||||
b < 1024 -> "$b B"
|
||||
b < 1024L * 1024 -> "${b / 1024} KB"
|
||||
b < 1024L * 1024 * 1024 -> "%.1f MB".format(b.toDouble() / (1024 * 1024))
|
||||
else -> "%.2f GB".format(b.toDouble() / (1024L * 1024 * 1024))
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Schedules FeedRefreshWorker via WorkManager based on Settings.
|
||||
* Called from StrawApp.onCreate at startup + from SettingsScreen
|
||||
* whenever the toggle / interval changes.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.feed
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.sulkta.straw.data.BgFeedRefreshInterval
|
||||
import com.sulkta.straw.data.Settings
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val WORK_NAME = "straw-feed-refresh"
|
||||
|
||||
object FeedRefreshScheduler {
|
||||
fun applyFromSettings(context: Context) {
|
||||
val s = Settings.get()
|
||||
val wm = WorkManager.getInstance(context.applicationContext)
|
||||
if (!s.bgFeedRefreshEnabled.value) {
|
||||
wm.cancelUniqueWork(WORK_NAME)
|
||||
return
|
||||
}
|
||||
// WorkManager 15-minute periodic floor — see UpdateScheduler.
|
||||
val request = PeriodicWorkRequestBuilder<FeedRefreshWorker>(
|
||||
s.bgFeedRefreshInterval.value.minutes.coerceAtLeast(15L),
|
||||
TimeUnit.MINUTES,
|
||||
).setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build(),
|
||||
).build()
|
||||
wm.enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val BgFeedRefreshInterval.minutes: Long
|
||||
get() = when (this) {
|
||||
BgFeedRefreshInterval.M30 -> 30
|
||||
BgFeedRefreshInterval.H1 -> 60
|
||||
BgFeedRefreshInterval.H6 -> 6 * 60
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Background subscription-feed refresh. Periodically calls
|
||||
* uniffi.strawcore.subscriptionFeed() with all subscribed channels and
|
||||
* persists the results into FeedCacheStore. Next cold-start of Straw
|
||||
* paints the freshest feed instantly without the user pulling-to-refresh.
|
||||
*
|
||||
* The the RSS swap dropped per-channel fetch time from ~500ms to
|
||||
* ~50-150ms, so a 50-sub refresh now costs ~1-2s total — small enough to
|
||||
* run quietly in the background on the user's chosen cadence.
|
||||
*
|
||||
* Disabled by default (opt-in via Settings). Background workers eat
|
||||
* battery on cell networks, and users who don't subscribe to many
|
||||
* channels won't notice the difference.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.feed
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.sulkta.straw.data.FeedCache
|
||||
import com.sulkta.straw.data.FeedCacheEntry
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.data.Subscriptions
|
||||
import com.sulkta.straw.feature.search.StreamItem
|
||||
import com.sulkta.straw.util.strawLogI
|
||||
import com.sulkta.straw.util.strawLogW
|
||||
|
||||
class FeedRefreshWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
if (!Settings.get().bgFeedRefreshEnabled.value) return Result.success()
|
||||
val subs = Subscriptions.get().subs.value
|
||||
if (subs.isEmpty()) return Result.success()
|
||||
strawLogI("FeedRefresh", "background tick: ${subs.size} channels")
|
||||
|
||||
// One bulk call via the Rust subscriptionFeed fan-out. Returns
|
||||
// a flat list; we group by uploaderUrl to rebuild the per-
|
||||
// channel cache shape FeedCacheStore expects.
|
||||
//
|
||||
// Distinguish transient failures (network down, timeout) from
|
||||
// parse failures. The former wants Result.retry() so
|
||||
// WorkManager re-attempts within the current window with
|
||||
// exponential backoff; without this, a 30-second offline blip
|
||||
// eats a full 6-hour refresh cycle.:
|
||||
// earlier `IOException` catch was dead code — UniFFI throws
|
||||
// `uniffi.strawcore.StrawcoreException.Network` for transport
|
||||
// errors, which does NOT extend IOException.
|
||||
val flat = try {
|
||||
uniffi.strawcore.subscriptionFeed(subs.map { it.url })
|
||||
} catch (e: uniffi.strawcore.StrawcoreException.Network) {
|
||||
strawLogW("FeedRefresh") { "transient network failure, retrying: ${e.message}" }
|
||||
return Result.retry()
|
||||
} catch (e: uniffi.strawcore.StrawcoreException.RequiresLogin) {
|
||||
// reCAPTCHA challenges clear on their own minutes-to-hours
|
||||
// later. Treating these as permanent eats a full refresh
|
||||
// cycle the same way the pre-fix IOException catch did.
|
||||
strawLogW("FeedRefresh") { "YT challenge, retrying: ${e.message}" }
|
||||
return Result.retry()
|
||||
} catch (e: Throwable) {
|
||||
strawLogW("FeedRefresh") { "non-transient failure, giving up this cycle: ${e.message}" }
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val grouped: Map<String, FeedCacheEntry> = flat
|
||||
.groupBy { it.uploaderUrl.orEmpty() }
|
||||
.filterKeys { it.isNotBlank() }
|
||||
.mapValues { (chUrl, items) ->
|
||||
FeedCacheEntry(
|
||||
fetchedAt = now,
|
||||
items = items.map { v ->
|
||||
StreamItem(
|
||||
url = v.url,
|
||||
title = v.title.ifBlank { "(no title)" },
|
||||
uploader = v.uploader,
|
||||
uploaderUrl = v.uploaderUrl ?: chUrl,
|
||||
thumbnail = v.thumbnail,
|
||||
durationSeconds = v.durationSeconds,
|
||||
viewCount = v.viewCount,
|
||||
uploadDateRelative = v.uploadDateRelative,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (grouped.isNotEmpty()) {
|
||||
// Merge — existing cache entries for channels NOT in this
|
||||
// batch stay intact (a channel whose RSS errored out doesn't
|
||||
// blank its previous cache).
|
||||
val before = FeedCache.get().load()
|
||||
val merged = before.toMutableMap()
|
||||
grouped.forEach { (k, v) -> merged[k] = v }
|
||||
FeedCache.get().save(merged)
|
||||
strawLogI("FeedRefresh", "wrote ${grouped.size} channels to FeedCache")
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
|
@ -2,31 +2,39 @@
|
|||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Phase Q: aggregate latest videos across all subscribed channels into a
|
||||
* single feed. Fans out per-channel ChannelInfo + ChannelTabs.VIDEOS
|
||||
* fetches in parallel, merges by view count desc, caps at 200 items.
|
||||
* Aggregate latest videos across all subscribed channels into a single
|
||||
* feed. Per-channel fan-out with independent TTL caches. Bigger per
|
||||
* channel limit so the feed actually feels "show me everything new",
|
||||
* sorted by parsed relative upload date so the merged list reads
|
||||
* newest-first across channels.
|
||||
*
|
||||
* Audit fixes (2026-05-24 pass #2):
|
||||
* HIGH-6: cancel any prior in-flight refresh when a new one starts, cap
|
||||
* concurrency with a Semaphore, time-bound each per-channel fetch so
|
||||
* one hung channel can't stall the whole feed.
|
||||
* MED-7: use `update { }` for atomic UI-state writes (matches the
|
||||
* convention applied to the data stores in audit pass #1).
|
||||
* Also opportunistically refreshes a channel's avatar in
|
||||
* SubscriptionsStore — strawcore can occasionally return null on first
|
||||
* subscribe (the channel header layout varies); a subsequent feed fetch
|
||||
* will fill it in automatically.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.feed
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.sulkta.straw.data.ChannelRef
|
||||
import com.sulkta.straw.data.Enrichment
|
||||
import com.sulkta.straw.data.FeedCache
|
||||
import com.sulkta.straw.data.FeedCacheEntry
|
||||
import com.sulkta.straw.data.FeedEnrichment
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.data.Subscriptions
|
||||
import com.sulkta.straw.feature.search.StreamItem
|
||||
import com.sulkta.straw.util.bestThumbnail
|
||||
import com.sulkta.straw.util.runCatchingCancellable
|
||||
import com.sulkta.straw.util.strawLogW
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -36,12 +44,7 @@ import kotlinx.coroutines.sync.Semaphore
|
|||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.ServiceList
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
|
||||
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
data class SubscriptionFeedUiState(
|
||||
val loading: Boolean = false,
|
||||
|
|
@ -51,101 +54,441 @@ data class SubscriptionFeedUiState(
|
|||
)
|
||||
|
||||
class SubscriptionFeedViewModel : ViewModel() {
|
||||
private val _ui = MutableStateFlow(SubscriptionFeedUiState())
|
||||
// Seed loading=true: the init block always either hydrates from
|
||||
// disk or fires a refresh, so the user should see the spinner
|
||||
// (or cached content under it) rather than a one-frame flash of
|
||||
// empty.
|
||||
private val _ui = MutableStateFlow(SubscriptionFeedUiState(loading = true))
|
||||
val ui: StateFlow<SubscriptionFeedUiState> = _ui.asStateFlow()
|
||||
|
||||
/** Cache feed for 10 min to avoid hammering YT on tab re-entry. */
|
||||
private val cacheTtlMs = 10L * 60 * 1000
|
||||
/**
|
||||
* Per-channel cache: each entry refreshes independently. Hydrated
|
||||
* from disk on init via FeedCacheStore so cold app starts can show
|
||||
* the last successful fetch instantly. ConcurrentHashMap because
|
||||
* fetchChannelInto writes concurrently from the per-channel
|
||||
* coroutines; mergeFromCache and refreshIfStale read.
|
||||
*/
|
||||
private val channelCache = ConcurrentHashMap<String, FeedCacheEntry>()
|
||||
|
||||
/** Per-channel fetch timeout — slowest channel can't stall the whole batch. */
|
||||
private val perChannelTimeoutMs = 15_000L
|
||||
/** Per-channel TTL — Refresh just re-fetches stale entries. */
|
||||
private val perChannelTtlMs = 30L * 60 * 1000
|
||||
|
||||
/** Cap parallel network fetches even with 100+ subs. */
|
||||
private val parallelism = 8
|
||||
|
||||
/** Live refresh job, so spam-tapping Refresh doesn't fan out racing fetches. */
|
||||
private var inFlight: Job? = null
|
||||
|
||||
fun refreshIfStale() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (_ui.value.items.isNotEmpty() && now - _ui.value.lastFetchedAt < cacheTtlMs) return
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val channels = Subscriptions.get().subs.value
|
||||
if (channels.isEmpty()) {
|
||||
_ui.update { SubscriptionFeedUiState(loading = false, items = emptyList()) }
|
||||
return
|
||||
}
|
||||
inFlight?.cancel()
|
||||
_ui.update { it.copy(loading = true, error = null) }
|
||||
inFlight = viewModelScope.launch {
|
||||
try {
|
||||
val items = withContext(Dispatchers.IO) {
|
||||
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
|
||||
val perChannelMax = 5
|
||||
val gate = Semaphore(parallelism)
|
||||
coroutineScope {
|
||||
val deferreds = channels.map { ch ->
|
||||
async {
|
||||
gate.withPermit {
|
||||
withTimeoutOrNull(perChannelTimeoutMs) {
|
||||
runCatching {
|
||||
val info = ChannelInfo.getInfo(service, ch.url)
|
||||
val tab = info.tabs.firstOrNull {
|
||||
it.contentFilters.contains(ChannelTabs.VIDEOS)
|
||||
} ?: info.tabs.firstOrNull()
|
||||
?: return@runCatching emptyList<StreamItem>()
|
||||
ChannelTabInfo.getInfo(service, tab)
|
||||
.relatedItems
|
||||
.filterIsInstance<StreamInfoItem>()
|
||||
.take(perChannelMax)
|
||||
.map { si ->
|
||||
StreamItem(
|
||||
url = si.url,
|
||||
title = si.name ?: "(no title)",
|
||||
uploader = si.uploaderName ?: ch.name,
|
||||
uploaderUrl = si.uploaderUrl ?: ch.url,
|
||||
thumbnail = bestThumbnail(si.thumbnails),
|
||||
durationSeconds = si.duration,
|
||||
viewCount = si.viewCount,
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
|
||||
}.getOrDefault(emptyList())
|
||||
} ?: run {
|
||||
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
deferreds.awaitAll()
|
||||
}
|
||||
.flatten()
|
||||
// No reliable upload-timestamp from extractor's StreamInfoItem
|
||||
// in all cases — sort by view count desc as a soft proxy for
|
||||
// recency-popularity within the recent window.
|
||||
.sortedByDescending { it.viewCount }
|
||||
.take(200)
|
||||
}
|
||||
_ui.update {
|
||||
SubscriptionFeedUiState(
|
||||
loading = false,
|
||||
items = items,
|
||||
lastFetchedAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
init {
|
||||
// Hydrate from disk and immediately render the cached items so
|
||||
// the Subs tab paints before the network round-trip resolves.
|
||||
// previously this ran synchronously on the
|
||||
// main thread at VM construction, blocking the first compose
|
||||
// pass on a ~225 KB Json.decodeFromString.
|
||||
viewModelScope.launch {
|
||||
if (!Settings.get().cacheEnabled.value) return@launch
|
||||
val saved = withContext(Dispatchers.IO) { FeedCache.get().load() }
|
||||
if (saved.isEmpty()) return@launch
|
||||
// putIfAbsent (not putAll) — refresh() may have started
|
||||
// populating fresh entries during our IO suspension; we
|
||||
// must not overwrite those with disk-stale values.
|
||||
saved.forEach { (url, entry) -> channelCache.putIfAbsent(url, entry) }
|
||||
val channels = Subscriptions.get().subs.value
|
||||
if (channels.isNotEmpty()) {
|
||||
pruneCacheToSubs(channels)
|
||||
val savedTs = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L
|
||||
// Compute the merge off-Main first.
|
||||
// FlatMap + regex + sort on hydration was
|
||||
// running on Main and could add ~10-20 ms to cold
|
||||
// start on a slow phone.
|
||||
val hydrated = withContext(Dispatchers.Default) { mergeFromCache(channels) }
|
||||
// _ui.update so a concurrent refresh()'s state write
|
||||
// doesn't race with this copy.
|
||||
// Only advance lastFetchedAt — never regress.
|
||||
_ui.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
error = t.message ?: t.javaClass.simpleName,
|
||||
items = hydrated,
|
||||
lastFetchedAt = maxOf(it.lastFetchedAt, savedTs),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-channel fetch timeout. 10s instead of 15s — a channel that
|
||||
* hasn't responded in 10s is likely a transient network hiccup or a
|
||||
* dead channel handle; better to drop it from the batch and ride
|
||||
* the disk-cache stale value than block the whole feed.
|
||||
*/
|
||||
private val perChannelTimeoutMs = 10_000L
|
||||
|
||||
/**
|
||||
* Parallel network fetches. Cranked from 12 → 50 previously alongside
|
||||
* the RSS-feed swap. Each fetch is now a ~5-15KB Atom XML payload
|
||||
* instead of a ~150KB InnerTube channel-page scrape — Tokio's
|
||||
* `buffer_unordered` inside `subscription_feed()` handles >50
|
||||
* concurrent without breaking a sweat, and the Kotlin gate just
|
||||
* keeps the launch fan-out bounded so we don't blow the file-
|
||||
* descriptor budget on a 200-sub user.
|
||||
*/
|
||||
private val parallelism = 50
|
||||
|
||||
/**
|
||||
* Videos pulled per channel. RSS returns up to 15 most-recent
|
||||
* videos per channel — that's the upstream cap, so 15 is our
|
||||
* effective ceiling here. We sort + interleave across all subs
|
||||
* client-side after the fan-out completes.
|
||||
*/
|
||||
private val perChannelMax = 15
|
||||
|
||||
/** Live refresh job, so spam-tapping Refresh doesn't fan out racing fetches. */
|
||||
private var inFlight: Job? = null
|
||||
|
||||
/**
|
||||
* The background enrichment job runs on StrawApp.globalScope so it
|
||||
* outlives the VM's viewModelScope — but a refresh-cancel must
|
||||
* still kill the *previous* enrichment so we don't pile up
|
||||
* overlapping fan-outs (8-wide × N overlapping refreshes blows the
|
||||
* concurrency budget). Tracked here, cancelled in the same places
|
||||
* `inFlight` is./3/8.
|
||||
*/
|
||||
private var enrichJob: Job? = null
|
||||
|
||||
fun refreshIfStale() {
|
||||
// Skip if a refresh is already in flight.:
|
||||
// SubsPane's LaunchedEffect(subs) re-fires every time
|
||||
// Subscriptions.updateAvatar emits a fresh list reference (which
|
||||
// fetchChannelInto does opportunistically per channel). Without
|
||||
// this gate, each per-channel avatar backfill cancels the
|
||||
// parallel-12 batch and turns the refresh into N sequential
|
||||
// single-channel fetches.
|
||||
if (inFlight?.isActive == true) return
|
||||
val now = System.currentTimeMillis()
|
||||
val anyStale = Subscriptions.get().subs.value.any { ch ->
|
||||
val entry = channelCache[ch.url]
|
||||
entry == null || now - entry.fetchedAt >= perChannelTtlMs
|
||||
}
|
||||
if (anyStale || _ui.value.items.isEmpty()) refreshInternal(force = false)
|
||||
}
|
||||
|
||||
fun refresh() = refreshInternal(force = true)
|
||||
|
||||
private fun refreshInternal(force: Boolean) {
|
||||
// Cancel any in-flight refresh at the TOP — including before
|
||||
// the empty-channels branch. Without this, a refresh that
|
||||
// ran on a non-empty sub set could still be writing to
|
||||
// channelCache when the user unsubscribes from the last
|
||||
// channel; we'd clear() then immediately repopulate with
|
||||
// phantom entries when the prior fetchChannelInto resolved.
|
||||
// Also kill any in-flight
|
||||
// enrichment fan-out so we don't end up with N overlapping
|
||||
// enrich jobs piling up under spam-refresh
|
||||
inFlight?.cancel()
|
||||
enrichJob?.cancel()
|
||||
val channels = Subscriptions.get().subs.value
|
||||
if (channels.isEmpty()) {
|
||||
_ui.update { it.copy(loading = false, items = emptyList(), error = null) }
|
||||
channelCache.clear()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { FeedCache.get().clear() }
|
||||
}
|
||||
return
|
||||
}
|
||||
_ui.update { it.copy(loading = true, error = null) }
|
||||
inFlight = viewModelScope.launch {
|
||||
try {
|
||||
val gate = Semaphore(parallelism)
|
||||
val now = System.currentTimeMillis()
|
||||
coroutineScope {
|
||||
// force=true (user tapped Refresh): fan out across
|
||||
// every subscribed channel. force=false (the auto
|
||||
// refreshIfStale path): only the stale entries.
|
||||
// — previously refresh also
|
||||
// filtered to stale-only, so a user-initiated tap
|
||||
// 5min after the last refresh was a silent no-op.
|
||||
channels
|
||||
.filter { ch ->
|
||||
if (force) return@filter true
|
||||
val entry = channelCache[ch.url]
|
||||
entry == null || now - entry.fetchedAt >= perChannelTtlMs
|
||||
}
|
||||
.map { ch -> async { gate.withPermit { fetchChannelInto(ch) } } }
|
||||
.awaitAll()
|
||||
}
|
||||
pruneCacheToSubs(channels)
|
||||
// Move flatMap + per-item regex + sort off Main —
|
||||
// viewModelScope.launch runs on Main by default and
|
||||
// mergeFromCache is non-trivial on a 500-item merge.
|
||||
// ensureActive AFTER the
|
||||
// withContext hop is: a
|
||||
// synchronous Default body doesn't observe
|
||||
// cancellation until the next suspension; without
|
||||
// this check, a cancel that landed mid-merge would
|
||||
// still let the terminal _ui.update fire and clobber
|
||||
// a fresher state.
|
||||
val freshItems = withContext(Dispatchers.Default) { mergeFromCache(channels) }
|
||||
coroutineContext.ensureActive()
|
||||
_ui.update {
|
||||
SubscriptionFeedUiState(
|
||||
loading = false,
|
||||
items = freshItems,
|
||||
lastFetchedAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
// hybrid backfill. RSS-fed items have
|
||||
// viewCount=0 + durationSeconds=0; kick a bounded
|
||||
// background job that calls enrichFeedItem for the
|
||||
// top items and pumps a fresh _ui emit when done.
|
||||
// Pass the channels snapshot so the enrich job's
|
||||
// terminal mergeFromCache uses what was current at
|
||||
// job start, not whatever the user's subs are by
|
||||
// the time enrichment finishes ~2s later.
|
||||
enrichVisibleItems(freshItems, channels)
|
||||
// Persist what we just freshened. Off the main thread —
|
||||
// JSON encode on 30 subs * 30 items is small but not
|
||||
// free, and SharedPreferences.apply is async anyway.
|
||||
// Skipped entirely when the user has disabled caching.
|
||||
if (Settings.get().cacheEnabled.value) {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { FeedCache.get().save(channelCache.toMap()) }
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
// Re-throw cancellation so spam-tapping Refresh (or
|
||||
// toggling cache OFF→ON during a refresh) doesn't
|
||||
// surface a "refresh failed: StandaloneCoroutineCancelled"
|
||||
// banner above the cached items.
|
||||
if (t is CancellationException) throw t
|
||||
_ui.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
error = com.sulkta.straw.util.LogDump.scrubLine(
|
||||
t.message ?: t.javaClass.simpleName,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchChannelInto(ch: ChannelRef) {
|
||||
// swapped uniffi.strawcore.channelInfo (~500ms each,
|
||||
// full InnerTube page scrape with JS eval) for the RSS feed
|
||||
// (~50-150ms each, tiny Atom XML). Same fan-out architecture,
|
||||
// ~5-10× faster. Avatar backfill is skipped on this path —
|
||||
// RSS doesn't carry avatars; the existing avatar lazy-loads
|
||||
// when the user taps into the channel screen.
|
||||
val outcome = withTimeoutOrNull(perChannelTimeoutMs) {
|
||||
runCatchingCancellable {
|
||||
val videos = uniffi.strawcore.channelFeedRss(ch.url)
|
||||
videos.take(perChannelMax).map { v ->
|
||||
StreamItem(
|
||||
url = v.url,
|
||||
title = v.title.ifBlank { "(no title)" },
|
||||
uploader = v.uploader.ifBlank { ch.name },
|
||||
uploaderUrl = v.uploaderUrl ?: ch.url,
|
||||
thumbnail = v.thumbnail,
|
||||
// RSS doesn't carry duration or view count.
|
||||
// These backfill on tap-through when the user
|
||||
// opens the detail screen and we resolve full
|
||||
// streamInfo. 0 means "unknown" — the row
|
||||
// renderer hides the badges when 0.
|
||||
durationSeconds = v.durationSeconds,
|
||||
viewCount = v.viewCount,
|
||||
uploadDateRelative = v.uploadDateRelative,
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
|
||||
}.getOrDefault(emptyList())
|
||||
} ?: run {
|
||||
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
|
||||
emptyList()
|
||||
}
|
||||
// Only update the cache on a successful fetch. A timeout/error
|
||||
// leaves any prior cache entry intact, so a glitchy channel
|
||||
// doesn't blank your feed for that channel.
|
||||
if (outcome.isNotEmpty()) {
|
||||
channelCache[ch.url] = FeedCacheEntry(System.currentTimeMillis(), outcome)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pruneCacheToSubs(channels: List<ChannelRef>) {
|
||||
val subUrls = channels.map { it.url }.toSet()
|
||||
channelCache.keys.toList().forEach { if (it !in subUrls) channelCache.remove(it) }
|
||||
}
|
||||
|
||||
private fun mergeFromCache(channels: List<ChannelRef>): List<StreamItem> {
|
||||
// Pure read. Caller is responsible for calling pruneCacheToSubs
|
||||
// beforehand when channel-set changes matter — split here
|
||||
// because the prior version's "merge" name hid a side-effecting
|
||||
// prune that violated single-responsibility (.
|
||||
//
|
||||
// Pre-compute recencyScore once per item audit
|
||||
// MED-Q15: sortedWith's comparator was invoking the regex
|
||||
// twice per pair, so ~1800 regex matches on a 900-item merge.
|
||||
//
|
||||
// overlay FeedEnrichment data on each item so RSS-fed
|
||||
// rows (viewCount=0, durationSeconds=0) get backfilled with
|
||||
// metadata fetched by the background enrichment job below.
|
||||
// Pure read of the enrichment store; the enrichment write
|
||||
// path triggers a fresh _ui emit.
|
||||
val enrichments = FeedEnrichment.get().entries.value
|
||||
return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() }
|
||||
.map { it.withEnrichment(enrichments) }
|
||||
.map { it to it.recencyScore() }
|
||||
.sortedWith(
|
||||
compareByDescending<Pair<StreamItem, Long>> { it.second }
|
||||
.thenByDescending { it.first.viewCount },
|
||||
)
|
||||
.take(500)
|
||||
.map { it.first }
|
||||
}
|
||||
|
||||
/**
|
||||
* Background enrichment: pulls viewCount + durationSeconds for the
|
||||
* top-N freshly-merged items via the lightweight
|
||||
* uniffi.strawcore.enrichFeedItem endpoint. Bounded parallel
|
||||
* (8-wide) — each call is ~500ms full streamInfo, so 30 items
|
||||
* complete in ~2s. Skipped per-item when FeedEnrichment already
|
||||
* has a fresh hit (TTL controlled by Settings.cacheTtl).
|
||||
*
|
||||
* Runs on viewModelScope: outliving the VM
|
||||
* would mean a destroyed _ui can still receive a stale emit (and
|
||||
* mergeFromCache reads a now-cleared channelCache). The next
|
||||
* VM instance does its own enrichment on next refresh; nothing
|
||||
* is lost by not finishing the prior one. Tracked in enrichJob so
|
||||
* refresh + clearInMemoryCache can cancel it.
|
||||
*/
|
||||
private fun enrichVisibleItems(items: List<StreamItem>, channelsSnapshot: List<ChannelRef>) {
|
||||
val take = items.take(ENRICH_HEAD_COUNT)
|
||||
.filter { it.viewCount <= 0L && it.durationSeconds <= 0L }
|
||||
if (take.isEmpty()) return
|
||||
enrichJob?.cancel()
|
||||
enrichJob = viewModelScope.launch {
|
||||
val gate = Semaphore(ENRICH_PARALLELISM)
|
||||
coroutineScope {
|
||||
take.map { item ->
|
||||
async {
|
||||
gate.withPermit {
|
||||
val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(item.url)
|
||||
?: return@withPermit
|
||||
// Defense in depth: enrichFeedItem calls
|
||||
// strawcore.stream_info which expects a
|
||||
// canonical YT URL. A poisoned cached
|
||||
// item.url shouldn't be able to reach the
|
||||
// extractor either. All uniffi.strawcore.*
|
||||
// sites that take a user-influenced URL get
|
||||
// the same gate.
|
||||
if (!com.sulkta.straw.util.isAllowedYtUrl(item.url)) return@withPermit
|
||||
if (FeedEnrichment.get().get(videoId) != null) return@withPermit
|
||||
val md = runCatchingCancellable {
|
||||
withContext(Dispatchers.IO) {
|
||||
uniffi.strawcore.enrichFeedItem(item.url)
|
||||
}
|
||||
}.getOrNull() ?: return@withPermit
|
||||
FeedEnrichment.get().put(
|
||||
videoId,
|
||||
md.viewCount,
|
||||
md.durationSeconds,
|
||||
)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
// Compute the merge off-Main — flatMap + per-item regex
|
||||
// + sort over up to 500 items is too much for the UI
|
||||
// thread. Then hop to Main only for the StateFlow emit.
|
||||
//
|
||||
// Re-read subs at the terminal step.
|
||||
// : the snapshot captured at refresh-end may
|
||||
// include channels the user has since unsubscribed from
|
||||
// in the ~2s enrich window. Intersect so a freshly-
|
||||
// unsubscribed channel doesn't briefly re-appear in the
|
||||
// feed after the enrich emit.:
|
||||
// hoist the snapshot-URL set once instead of rebuilding
|
||||
// it per filter iteration.
|
||||
val snapshotUrls = channelsSnapshot.mapTo(HashSet()) { it.url }
|
||||
val mergeChannels = Subscriptions.get().subs.value
|
||||
.filter { it.url in snapshotUrls }
|
||||
val merged = withContext(Dispatchers.Default) {
|
||||
mergeFromCache(mergeChannels)
|
||||
}
|
||||
// Honor cancellation post-merge
|
||||
coroutineContext.ensureActive()
|
||||
_ui.update { it.copy(items = merged) }
|
||||
}
|
||||
}
|
||||
|
||||
private val ENRICH_HEAD_COUNT = 30
|
||||
private val ENRICH_PARALLELISM = 8
|
||||
|
||||
/**
|
||||
* Apply an enrichment overlay to a StreamItem. Only fills fields
|
||||
* that RSS left empty — if the source already had non-zero values
|
||||
* (e.g. a channelInfo path populated them) we don't clobber.
|
||||
*/
|
||||
private fun StreamItem.withEnrichment(
|
||||
enrichments: Map<String, Enrichment>,
|
||||
): StreamItem {
|
||||
if (viewCount > 0L && durationSeconds > 0L) return this
|
||||
val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(url) ?: return this
|
||||
val e = enrichments[videoId] ?: return this
|
||||
return copy(
|
||||
viewCount = if (viewCount > 0L) viewCount else e.viewCount,
|
||||
durationSeconds = if (durationSeconds > 0L) durationSeconds else e.durationSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear in-memory cache. Called from Settings when the user flips
|
||||
* off the local-cache toggle — disk wipe via FeedCacheStore.clear()
|
||||
* was already there, but the VM kept its in-memory mirror so items
|
||||
* stayed visible until process death. audit MED-C13.
|
||||
*/
|
||||
fun clearInMemoryCache() {
|
||||
// Cancel any in-flight refresh — without this, fetchChannelInto
|
||||
// coroutines mid-execution would re-populate the cache after
|
||||
// the clear. Also cancel any
|
||||
// enrichment fan-out (lives on globalScope, NOT viewModelScope)
|
||||
// — otherwise a still-running enrichment would write to
|
||||
// FeedEnrichment + then push a merged emit reading the empty
|
||||
// channelCache.
|
||||
inFlight?.cancel()
|
||||
enrichJob?.cancel()
|
||||
channelCache.clear()
|
||||
// Use _ui.update for atomicity vs concurrent refresh writes
|
||||
_ui.update { it.copy(items = emptyList(), lastFetchedAt = 0L) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert "2 days ago" / "3 weeks ago" / "Streamed 5 hours ago" style
|
||||
* strings into approximate seconds-ago. Higher = more recent (so default
|
||||
* sort is descending). Returns Long.MIN_VALUE when we can't parse — those
|
||||
* sink to the bottom of the feed.
|
||||
*
|
||||
* Strawcore-core (and YT before it) emits these in English-only locale
|
||||
* for the InnerTube web client; if we ever localize the extractor this
|
||||
* regex needs to grow.
|
||||
*/
|
||||
private val RECENCY_RE = Regex(
|
||||
"""(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
private fun StreamItem.recencyScore(): Long {
|
||||
val s = uploadDateRelative
|
||||
if (s.isBlank()) return Long.MIN_VALUE
|
||||
val m = RECENCY_RE.find(s) ?: return Long.MIN_VALUE
|
||||
val n = m.groupValues[1].toLongOrNull() ?: return Long.MIN_VALUE
|
||||
val unitSecs: Long = when (m.groupValues[2].lowercase()) {
|
||||
"second" -> 1
|
||||
"minute" -> 60
|
||||
"hour" -> 3600
|
||||
"day" -> 86_400
|
||||
"week" -> 604_800
|
||||
"month" -> 2_592_000 // approx 30 days
|
||||
"year" -> 31_536_000
|
||||
else -> return Long.MIN_VALUE
|
||||
}
|
||||
// Sign flip: smaller "seconds ago" → larger score (more recent).
|
||||
// Cap at a sane horizon so a "1 second ago" doesn't overwhelm the
|
||||
// viewCount tiebreaker on items that are functionally tied.
|
||||
return -(n * unitSecs)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* The minibar: a thin persistent strip pinned to the bottom of every
|
||||
* non-Player screen whenever a video is loaded into the MediaController.
|
||||
* Tap to expand back to fullscreen. The × clears playback and dismisses.
|
||||
*
|
||||
* The actual player + audio lives in PlaybackService — this composable
|
||||
* is purely UI on top of the MediaController. Pause/play toggles the
|
||||
* controller, which is the same player feeding the fullscreen surface
|
||||
* and the inline detail player. There is only ever one player.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.player
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import coil3.compose.AsyncImage
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun MinibarOverlay(
|
||||
onExpand: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val controller = LocalStrawController.current
|
||||
val item by NowPlaying.current.collectAsStateWithLifecycle()
|
||||
if (controller == null || item == null) return
|
||||
val cur = item ?: return
|
||||
|
||||
// Reflect the controller's play state in the play/pause icon. Listening
|
||||
// is the only reliable way; isPlaying snapshots stale between events.
|
||||
var isPlaying by remember { mutableStateOf(controller.isPlaying) }
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
DisposableEffect(controller) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
isPlaying = playing
|
||||
}
|
||||
// audit MED-Q11: if Background-button took the user
|
||||
// to Home and the foreground audio fails, the only Player
|
||||
// surface still listening is this minibar.
|
||||
// + Q11: also stop the controller so a
|
||||
// future tap doesn't seek into the dead state, AND clear
|
||||
// NowPlaying so the minibar hides itself. (PlayerScreen
|
||||
// and VideoDetailScreen's listeners also clear NowPlaying
|
||||
// now, so this is the fallback when neither is alive.)
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
android.widget.Toast.makeText(
|
||||
ctx,
|
||||
"playback error: ${error.errorCodeName}",
|
||||
android.widget.Toast.LENGTH_LONG,
|
||||
).show()
|
||||
runCatching {
|
||||
controller.stop()
|
||||
controller.clearMediaItems()
|
||||
}
|
||||
NowPlaying.clear()
|
||||
}
|
||||
}
|
||||
controller.addListener(listener)
|
||||
isPlaying = controller.isPlaying
|
||||
onDispose { controller.removeListener(listener) }
|
||||
}
|
||||
|
||||
// navigationBarsPadding shifts the whole minibar up by the system
|
||||
// nav-bar height so the bar sits ABOVE the gesture pill / 3-button
|
||||
// nav, not behind them. enableEdgeToEdge in StrawActivity means
|
||||
// anything aligned BottomCenter lands under those buttons otherwise.
|
||||
Column(modifier = modifier.fillMaxWidth().navigationBarsPadding()) {
|
||||
HorizontalDivider()
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.clickable(onClick = onExpand),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = cur.thumbnail,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(width = 80.dp, height = 48.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color.Black),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
cur.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
cur.uploader,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
MinibarIconButton(
|
||||
icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
desc = if (isPlaying) "Pause" else "Play",
|
||||
) {
|
||||
if (controller.isPlaying) controller.pause() else controller.play()
|
||||
}
|
||||
MinibarIconButton(icon = Icons.Filled.Close, desc = "Stop") {
|
||||
controller.stop()
|
||||
controller.clearMediaItems()
|
||||
NowPlaying.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MinibarIconButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
desc: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(imageVector = icon, contentDescription = desc, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Singleton "currently active video" state — drives the minibar overlay
|
||||
* and tells screens whether their video matches what's playing. Updated
|
||||
* by whichever surface starts playback (VideoDetail tap, Player Play
|
||||
* button, playlist item tap). Cleared by the minibar's × button.
|
||||
*
|
||||
* Why a process-wide singleton instead of a ViewModel: the minibar is
|
||||
* rendered at the activity layout level and needs to outlive any
|
||||
* specific Screen.* composable. Same shape as Subscriptions / Playlists
|
||||
* — runtime-only here since there's no persistence (session-scoped).
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.player
|
||||
|
||||
import com.sulkta.straw.net.SbSegment
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
data class NowPlayingItem(
|
||||
val streamUrl: String,
|
||||
val title: String,
|
||||
val uploader: String,
|
||||
/**
|
||||
* Uploader's channel URL — needed by the autoplay path so the
|
||||
* end-of-video handler can call channelInfo() to find the next
|
||||
* same-channel candidate. Optional because some items come from
|
||||
* paths where we don't have it (deep links, history rows on a
|
||||
* cold start before strawcore has resolved metadata).
|
||||
*/
|
||||
val uploaderUrl: String? = null,
|
||||
val thumbnail: String?,
|
||||
val segments: List<SbSegment> = emptyList(),
|
||||
)
|
||||
|
||||
object NowPlaying {
|
||||
private val _current = MutableStateFlow<NowPlayingItem?>(null)
|
||||
val current: StateFlow<NowPlayingItem?> = _current.asStateFlow()
|
||||
|
||||
/**
|
||||
* Atomically claim playback for `streamUrl`. Returns true if this
|
||||
* call WON the claim (caller should now do setMediaItem + prepare +
|
||||
* play). Returns false if someone else has already set the same
|
||||
* streamUrl — typically because the inline-player effect and the
|
||||
* fullscreen Player effect both fired in the same window during
|
||||
* an inline→fullscreen transition. The losing caller does nothing;
|
||||
* the winning caller's playback is already in flight.
|
||||
*
|
||||
* Uses MutableStateFlow.compareAndSet for the race-free transition.
|
||||
* audit HIGH-C6 — the previous "check NowPlaying then
|
||||
* direct assign" sequence had a window where both checks could
|
||||
* pass before either write happened. The non-CAS `set()` setter
|
||||
* that lived alongside this method was dropped in round-5 (no
|
||||
* external callers; left in code purely as a footgun).
|
||||
*/
|
||||
fun claim(item: NowPlayingItem): Boolean {
|
||||
while (true) {
|
||||
val cur = _current.value
|
||||
if (cur?.streamUrl == item.streamUrl) {
|
||||
// Same URL — caller doesn't need to re-prepare the
|
||||
// player, but if it brought richer metadata (full
|
||||
// title vs the search-result truncation, fresh
|
||||
// thumbnail, updated SponsorBlock segments) refresh
|
||||
// those fields.
|
||||
if (cur != item) _current.compareAndSet(cur, item)
|
||||
return false
|
||||
}
|
||||
if (_current.compareAndSet(cur, item)) return true
|
||||
// Lost the CAS to a concurrent writer — retry against the
|
||||
// fresh state. Bounded: at most a handful of competing
|
||||
// callers in practice.
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_current.value = null
|
||||
}
|
||||
}
|
||||
|
|
@ -2,159 +2,376 @@
|
|||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Phase S: foreground-service ExoPlayer for "Background" audio mode.
|
||||
* Independent of the activity-side player. When the user taps Background
|
||||
* on the player overlay, the activity stops its own playback and starts
|
||||
* this service with the audio URL. Audio continues even if the activity
|
||||
* is killed (swipe out of recents).
|
||||
* Universal player for Straw. Owns the single ExoPlayer + MediaSession.
|
||||
* Every UI surface (inline player on VideoDetail, fullscreen PlayerScreen,
|
||||
* the minibar overlay) is a MediaController client talking to this
|
||||
* session — so playback never restarts on a screen transition and a
|
||||
* dragged-down player just keeps going at the bottom of the layout.
|
||||
*
|
||||
* Audit fixes (2026-05-24 pass #2):
|
||||
* CRIT-1: call startForeground() immediately on first onStartCommand so
|
||||
* Android 12+ doesn't kill the process with
|
||||
* ForegroundServiceDidNotStartInTimeException after the 5s window.
|
||||
* HIGH-2: return START_NOT_STICKY when there is no playable URL — the
|
||||
* OS will not relaunch us with a null intent and crash-loop.
|
||||
* HIGH-3: stop the service when playback ends (Player.Listener) so the
|
||||
* WAKE_LOCK / foreground notification doesn't linger.
|
||||
* MED-1: null the field before releasing the session to close a tiny
|
||||
* onGetSession race during teardown.
|
||||
* The service is brought up automatically the first time the activity
|
||||
* builds a MediaController against `SessionToken(ctx, ComponentName)`.
|
||||
* It transitions to foreground when playback starts (Media3 handles the
|
||||
* required notification); it stops itself when idle (no controllers
|
||||
* connected AND nothing in the queue).
|
||||
*
|
||||
* Limitations:
|
||||
* - Single URL only. The activity-side merged-DASH path doesn't carry
|
||||
* over (we just use the best audioStream). Acceptable trade-off for
|
||||
* background mode.
|
||||
* - No SponsorBlock skip here. That logic lives in PlayerScreen and is
|
||||
* foreground-only for now.
|
||||
* - Service plays one item at a time. Queue/playlist is future work.
|
||||
* Media source dispatch lives in [StrawMediaSourceFactory] below. It
|
||||
* routes by MIME type for DASH / HLS / progressive and merges video +
|
||||
* audio when the audio URL is carried in the MediaItem's
|
||||
* `requestMetadata.extras[EXTRA_AUDIO_URL]`.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.player
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.dash.DashMediaSource
|
||||
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.MergingMediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.sulkta.straw.StrawActivity
|
||||
import com.sulkta.straw.extractor.NewPipeDownloader
|
||||
import com.sulkta.straw.StrawApp
|
||||
import com.sulkta.straw.data.AutoplayMode
|
||||
import com.sulkta.straw.data.History
|
||||
import com.sulkta.straw.data.Resume
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.feature.detail.resolveStreamPlayback
|
||||
import com.sulkta.straw.net.IosSafeHttpDataSource
|
||||
import com.sulkta.straw.net.STRAW_USER_AGENT
|
||||
import com.sulkta.straw.net.SponsorBlockClient
|
||||
import com.sulkta.straw.util.isAllowedYtUrl
|
||||
import com.sulkta.straw.util.runCatchingCancellable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@UnstableApi
|
||||
class PlaybackService : MediaSessionService() {
|
||||
|
||||
private var mediaSession: MediaSession? = null
|
||||
private var foregroundStarted = false
|
||||
private var settingsWatcherJob: Job? = null
|
||||
private var resumePollJob: Job? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ensureChannel()
|
||||
|
||||
val httpFactory = DefaultHttpDataSource.Factory()
|
||||
.setUserAgent(NewPipeDownloader.USER_AGENT)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(this)
|
||||
.setDataSourceFactory(httpFactory)
|
||||
// Path C-7: wrap in IosSafeHttpDataSource so ExoPlayer's open-ended
|
||||
// Range requests get chunked into bounded reads. iOS-bound
|
||||
// googlevideo URLs 403 on `Range: bytes=N-` but accept `Range:
|
||||
// bytes=N-M`.
|
||||
val httpFactory = IosSafeHttpDataSource.Factory(
|
||||
DefaultHttpDataSource.Factory()
|
||||
.setUserAgent(STRAW_USER_AGENT)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
)
|
||||
|
||||
val mediaSourceFactory = StrawMediaSourceFactory(httpFactory)
|
||||
|
||||
val player = ExoPlayer.Builder(this)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true,
|
||||
)
|
||||
// Honor the user's pause-on-headphone-disconnect preference
|
||||
// at construction time. The Settings flow is also watched
|
||||
// below so flipping it mid-session takes effect immediately.
|
||||
.setHandleAudioBecomingNoisy(
|
||||
Settings.get().pauseOnHeadphoneDisconnect.value,
|
||||
)
|
||||
.build()
|
||||
|
||||
// HIGH-3: end-of-playback should release the foreground slot.
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (state == Player.STATE_ENDED || state == Player.STATE_IDLE) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
})
|
||||
// Service shutdown is driven by onTaskRemoved (user swiped app away)
|
||||
// + the user pressing × on the minibar (which clears the queue).
|
||||
// Don't auto-stop on STATE_ENDED — a future autoplay/queue feature
|
||||
// expects the service to stay alive between items in the queue.
|
||||
// Foreground notification fades on its own when nothing is playing.
|
||||
|
||||
val sessionActivityIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, StrawActivity::class.java),
|
||||
Intent(this, StrawActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
mediaSession = MediaSession.Builder(this, player)
|
||||
.setId(MEDIA_SESSION_ID)
|
||||
.setSessionActivity(sessionActivityIntent)
|
||||
.build()
|
||||
|
||||
// Watch the pause-on-headphone-disconnect setting so flipping
|
||||
// it in Settings takes effect on this already-built ExoPlayer
|
||||
// without requiring a service restart. The initial value was
|
||||
// baked in via the builder above — this picks up subsequent
|
||||
// flips.
|
||||
settingsWatcherJob = StrawApp.globalScope.launch {
|
||||
Settings.get().pauseOnHeadphoneDisconnect.collect { handle ->
|
||||
player.setHandleAudioBecomingNoisy(handle)
|
||||
}
|
||||
}
|
||||
|
||||
// Queue auto-advance bridge: when Media3 transitions to the
|
||||
// next item in the queue, look up the matching NowPlayingItem
|
||||
// (with original streamUrl, uploader, thumbnail, SB segments)
|
||||
// and push it into NowPlaying so the minibar + SponsorBlock
|
||||
// skip-loop reflect the new track. claim() handles concurrent
|
||||
// setPlayingFrom races — see audit HIGH-C6.
|
||||
//
|
||||
// SponsorBlock for queued items: when a queued item's segments
|
||||
// are empty (which they always are — enqueueNext/Last doesn't
|
||||
// pre-fetch SB to avoid the network round-trip on every long-
|
||||
// press), kick off a background fetch and re-claim with the
|
||||
// freshened segments. NowPlaying.claim handles the
|
||||
// "same-streamUrl with fresher metadata" case via its CAS.
|
||||
//
|
||||
// Autoplay at end-of-queue: when STATE_ENDED fires and there's
|
||||
// no next item in the queue, consult Settings.autoplayMode and
|
||||
// pick a candidate. SameChannel → call channelInfo on the
|
||||
// current uploader, take the first un-watched (gated on
|
||||
// autoplaySkipWatched). YtRelated → would re-call streamInfo
|
||||
// and pick info.related[0] but strawcore returns empty for
|
||||
// related today, so it's a no-op until that lands.
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
|
||||
if (item == null) return
|
||||
val idx = player.currentMediaItemIndex
|
||||
val queued = Queue.at(idx)
|
||||
if (queued != null) {
|
||||
NowPlaying.claim(queued)
|
||||
if (queued.segments.isEmpty()) {
|
||||
val videoId =
|
||||
com.sulkta.straw.feature.detail.extractYtVideoId(queued.streamUrl)
|
||||
if (!videoId.isNullOrBlank()) fetchSbForQueued(queued, videoId)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Queue desync — MediaItem was added by a path that
|
||||
// bypassed enqueueInternal, OR the queue was cleared
|
||||
// while a transition was pending. Fall back to the
|
||||
// MediaItem's own metadata so NowPlaying doesn't stay
|
||||
// stuck on the previous video forever (would freeze
|
||||
// VideoDetail's controllerOnThisVideo guard at false
|
||||
// and lock the inline player into thumbnail+spinner).
|
||||
val uri = item.localConfiguration?.uri?.toString() ?: return
|
||||
val fallback = NowPlayingItem(
|
||||
streamUrl = uri,
|
||||
title = item.mediaMetadata.title?.toString().orEmpty(),
|
||||
uploader = item.mediaMetadata.artist?.toString().orEmpty(),
|
||||
thumbnail = item.mediaMetadata.artworkUri?.toString(),
|
||||
)
|
||||
NowPlaying.claim(fallback)
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
// Capture on every play→pause edge. Covers user taps,
|
||||
// audio focus loss, headphone-noisy pause. The 5s poll
|
||||
// covers the play-through case.
|
||||
if (!isPlaying) captureResumePosition(player)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (state != Player.STATE_ENDED) return
|
||||
val mode = Settings.get().autoplayMode.value
|
||||
if (mode == AutoplayMode.Off) return
|
||||
// Media3 auto-advances inside the queue; we only kick
|
||||
// in when the queue has truly run out. mediaItemCount
|
||||
// hits 0 after the engine reports STATE_ENDED in some
|
||||
// edge cases — handle both.
|
||||
val atEnd = player.mediaItemCount <= 1 ||
|
||||
player.currentMediaItemIndex >= player.mediaItemCount - 1
|
||||
if (!atEnd) return
|
||||
tryAutoplay(mode)
|
||||
}
|
||||
})
|
||||
|
||||
// Periodic scrub-point write. Stays on Main so player reads are
|
||||
// thread-safe; the SP write inside record() is async (apply()).
|
||||
// 5s cadence is the sweet spot — finer is wasted disk churn,
|
||||
// coarser loses too much on a sudden process death.
|
||||
resumePollJob = StrawApp.globalScope.launch(Dispatchers.Main) {
|
||||
while (isActive) {
|
||||
delay(RESUME_POLL_INTERVAL_MS)
|
||||
captureResumePosition(player)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current player position and persist it to the
|
||||
* ResumePositionsStore. Bails on idle/ended states and unknown
|
||||
* durations (live streams). The store itself enforces minimum-
|
||||
* position + near-end-clear thresholds.
|
||||
*
|
||||
* Gates STRICTLY on STATE_READY. STATE_BUFFERING during a fresh
|
||||
* setMediaItem still reports the PREVIOUS item's position via
|
||||
* currentPosition until prepare finishes and the new timeline
|
||||
* lands — without the gate we'd record A's tail position under
|
||||
* B's videoId and auto-resume the user mid-A on next open.
|
||||
*/
|
||||
private fun captureResumePosition(player: Player) {
|
||||
val state = player.playbackState
|
||||
if (state != Player.STATE_READY) return
|
||||
val item = NowPlaying.current.value ?: return
|
||||
val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(item.streamUrl) ?: return
|
||||
val pos = player.currentPosition
|
||||
val dur = player.duration
|
||||
if (dur <= 0L) return
|
||||
Resume.get().record(videoId, pos, dur)
|
||||
}
|
||||
|
||||
private fun fetchSbForQueued(item: NowPlayingItem, videoId: String) {
|
||||
StrawApp.globalScope.launch {
|
||||
runCatchingCancellable {
|
||||
val cats = Settings.get().sbCategories.value.map { it.key }
|
||||
if (cats.isEmpty()) return@runCatchingCancellable
|
||||
val segments = withContext(Dispatchers.IO) {
|
||||
SponsorBlockClient.fetch(videoId, cats)
|
||||
}
|
||||
if (segments.isNotEmpty()) {
|
||||
NowPlaying.claim(item.copy(segments = segments))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryAutoplay(mode: AutoplayMode) {
|
||||
val current = NowPlaying.current.value ?: return
|
||||
val uploaderUrl = current.uploaderUrl
|
||||
// We need the channel URL for the SameChannel path; YtRelated
|
||||
// re-resolves the current video's info. If we don't have what
|
||||
// we need, silently bail — better than a half-baked surprise.
|
||||
val controller = (mediaSession?.player as? Player) ?: return
|
||||
StrawApp.globalScope.launch {
|
||||
runCatchingCancellable {
|
||||
val candidateUrl = withContext(Dispatchers.IO) {
|
||||
pickAutoplayCandidate(mode, current.streamUrl, uploaderUrl)
|
||||
} ?: return@runCatchingCancellable
|
||||
// Final allowlist gate before we hit strawcore with a
|
||||
// URL whose origin was the extractor. Same defense as
|
||||
// VideoDetailViewModel.load. /
|
||||
// HIGH-3 family — every uniffi.strawcore.* site that
|
||||
// takes a user-influenced URL needs this gate.
|
||||
if (!isAllowedYtUrl(candidateUrl)) return@runCatchingCancellable
|
||||
// Resolve + enqueue + auto-play. Because the queue is
|
||||
// currently empty (we just ended), enqueueLast routes
|
||||
// through setPlayingFrom (auto-starts).
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
uniffi.strawcore.streamInfo(candidateUrl)
|
||||
}
|
||||
val resolved = resolveStreamPlayback(info)
|
||||
withContext(Dispatchers.Main) {
|
||||
controller.enqueueLast(
|
||||
streamUrl = candidateUrl,
|
||||
title = info.title,
|
||||
uploader = info.uploader,
|
||||
thumbnail = info.thumbnail,
|
||||
resolved = resolved,
|
||||
uploaderUrl = info.uploaderUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pickAutoplayCandidate(
|
||||
mode: AutoplayMode,
|
||||
currentStreamUrl: String,
|
||||
uploaderUrl: String?,
|
||||
): String? {
|
||||
val watched = if (Settings.get().autoplaySkipWatched.value) {
|
||||
History.get().watches.value.map { it.videoId }.toSet()
|
||||
} else emptySet()
|
||||
fun unwatched(url: String): Boolean {
|
||||
if (watched.isEmpty()) return true
|
||||
val id = com.sulkta.straw.feature.detail.extractYtVideoId(url)
|
||||
return id == null || id !in watched
|
||||
}
|
||||
return try {
|
||||
when (mode) {
|
||||
AutoplayMode.Off -> null
|
||||
AutoplayMode.SameChannel -> {
|
||||
if (uploaderUrl.isNullOrBlank()) return null
|
||||
// uploaderUrl came from the extractor and flows
|
||||
// through NowPlaying without revalidation. Same
|
||||
// gate as the inline channelInfo path.
|
||||
if (!isAllowedYtUrl(uploaderUrl)) return null
|
||||
val ch = uniffi.strawcore.channelInfo(uploaderUrl)
|
||||
ch.videos
|
||||
.asSequence()
|
||||
.filter { it.url != currentStreamUrl }
|
||||
.filter { unwatched(it.url) }
|
||||
.firstOrNull()?.url
|
||||
}
|
||||
AutoplayMode.YtRelated -> {
|
||||
val info = uniffi.strawcore.streamInfo(currentStreamUrl)
|
||||
info.related
|
||||
.asSequence()
|
||||
.filter { it.url != currentStreamUrl }
|
||||
.filter { unwatched(it.url) }
|
||||
.firstOrNull()?.url
|
||||
}
|
||||
}
|
||||
} catch (c: kotlinx.coroutines.CancellationException) {
|
||||
throw c
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetSession(
|
||||
controllerInfo: MediaSession.ControllerInfo,
|
||||
): MediaSession? = mediaSession
|
||||
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int {
|
||||
// CRIT-1: must startForeground within ~5s of startForegroundService,
|
||||
// before anything that can throw or block.
|
||||
startForegroundCompat()
|
||||
|
||||
val url = intent?.getStringExtra(EXTRA_URL)?.takeIf { isAllowedAudioUrl(it) }
|
||||
val title = intent?.getStringExtra(EXTRA_TITLE)
|
||||
val uploader = intent?.getStringExtra(EXTRA_UPLOADER)
|
||||
val player = mediaSession?.player
|
||||
if (url == null || player == null) {
|
||||
// HIGH-2: nothing to play (likely a re-launch with null intent
|
||||
// after a kill). Tear down so we don't sit holding the FG slot.
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val item = MediaItem.Builder()
|
||||
.setUri(url)
|
||||
.setMediaMetadata(
|
||||
androidx.media3.common.MediaMetadata.Builder()
|
||||
.setTitle(title ?: "")
|
||||
.setArtist(uploader ?: "")
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
player.setMediaItem(item)
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* When the user swipes the app out of Recents, only kill the service
|
||||
* if playback isn't running. If the user is intentionally backgrounding
|
||||
* to keep music going, we stay alive.
|
||||
*/
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
// HIGH-3: keep service alive ONLY while playback is genuinely in
|
||||
// progress. After STATE_ENDED, playWhenReady stays true but state
|
||||
// is ENDED — old check missed that and held WAKE_LOCK forever.
|
||||
val p = mediaSession?.player
|
||||
val keep = p != null &&
|
||||
val keepAlive = p != null &&
|
||||
p.playWhenReady &&
|
||||
p.mediaItemCount > 0 &&
|
||||
p.playbackState != Player.STATE_IDLE &&
|
||||
p.playbackState != Player.STATE_ENDED
|
||||
if (!keep) stopSelf()
|
||||
if (!keepAlive) stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// MED-1: null the field first so a late onGetSession from the
|
||||
// controller-binding teardown gets null instead of a released session.
|
||||
// Final scrub-point snapshot before teardown — covers swipe-
|
||||
// away-without-pause case. Read before cancelling the poll
|
||||
// job (the job's last tick may not have landed yet).
|
||||
mediaSession?.player?.let { captureResumePosition(it) }
|
||||
resumePollJob?.cancel()
|
||||
resumePollJob = null
|
||||
settingsWatcherJob?.cancel()
|
||||
settingsWatcherJob = null
|
||||
// Null the field first so a late onGetSession during teardown gets
|
||||
// null rather than a released session.
|
||||
val s = mediaSession
|
||||
mediaSession = null
|
||||
s?.player?.release()
|
||||
|
|
@ -162,71 +379,82 @@ class PlaybackService : MediaSessionService() {
|
|||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startForegroundCompat() {
|
||||
if (foregroundStarted) return
|
||||
val tap = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, StrawActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification: Notification = NotificationCompat.Builder(this, NOTIF_CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||
.setContentTitle("Straw")
|
||||
.setContentText("Background audio")
|
||||
.setContentIntent(tap)
|
||||
.setOngoing(true)
|
||||
.setCategory(Notification.CATEGORY_TRANSPORT)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIF_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notification)
|
||||
}
|
||||
foregroundStarted = true
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = getSystemService(NotificationManager::class.java) ?: return
|
||||
if (nm.getNotificationChannel(NOTIF_CHANNEL_ID) != null) return
|
||||
val ch = NotificationChannel(
|
||||
NOTIF_CHANNEL_ID,
|
||||
"Background audio",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Straw audio playback while the app is in background"
|
||||
setShowBadge(false)
|
||||
}
|
||||
nm.createNotificationChannel(ch)
|
||||
}
|
||||
|
||||
/**
|
||||
* HIGH-4 mirror on the service side: the URL in EXTRA_URL came from
|
||||
* NewPipeExtractor's audioStream.content. Re-validate host + scheme
|
||||
* before handing it to ExoPlayer's HTTP source. Only YT googlevideo
|
||||
* hosts allowed; HTTPS only.
|
||||
*/
|
||||
private fun isAllowedAudioUrl(url: String): Boolean {
|
||||
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
|
||||
if (!uri.scheme.equals("https", ignoreCase = true)) return false
|
||||
val host = uri.host?.lowercase() ?: return false
|
||||
return host.endsWith(".googlevideo.com") ||
|
||||
host.endsWith(".youtube.com") ||
|
||||
host == "youtube.com"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_URL = "com.sulkta.straw.extra.URL"
|
||||
const val EXTRA_TITLE = "com.sulkta.straw.extra.TITLE"
|
||||
const val EXTRA_UPLOADER = "com.sulkta.straw.extra.UPLOADER"
|
||||
const val MEDIA_SESSION_ID = "straw"
|
||||
|
||||
private const val NOTIF_CHANNEL_ID = "straw.playback"
|
||||
private const val NOTIF_ID = 4242
|
||||
/**
|
||||
* Bundle key — when set on a MediaItem's `requestMetadata.extras`,
|
||||
* the source factory will merge that audio URL with the
|
||||
* MediaItem's video URI to produce a combined video+audio source.
|
||||
*/
|
||||
const val EXTRA_AUDIO_URL = "straw.audio_url"
|
||||
|
||||
/** Scrub-point write cadence while the player is alive. */
|
||||
private const val RESUME_POLL_INTERVAL_MS = 5_000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MediaSource.Factory that picks the right inner source per MediaItem:
|
||||
*
|
||||
* - If `requestMetadata.extras[EXTRA_AUDIO_URL]` is set → MergingMediaSource
|
||||
* (progressive video + progressive audio).
|
||||
* - Else by MIME: application/dash+xml → DASH, application/x-mpegURL → HLS,
|
||||
* everything else → progressive.
|
||||
*
|
||||
* Lets us drive all stream shapes (DASH MPD, HLS, combined progressive,
|
||||
* separate video+audio progressive) through the single MediaController API
|
||||
* without exposing MediaSource directly to the UI layer.
|
||||
*/
|
||||
@UnstableApi
|
||||
class StrawMediaSourceFactory(
|
||||
private val dataSourceFactory: DataSource.Factory,
|
||||
) : MediaSource.Factory {
|
||||
private val dashFactory = DashMediaSource.Factory(dataSourceFactory)
|
||||
private val hlsFactory = HlsMediaSource.Factory(dataSourceFactory)
|
||||
private val progFactory = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
// For mime-sniffing fallthroughs we also fall back to DefaultMediaSourceFactory
|
||||
// so things like extractors-only progressive items keep working.
|
||||
private val defaultFactory = DefaultMediaSourceFactory(dataSourceFactory)
|
||||
|
||||
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
|
||||
val audioUrl = mediaItem.requestMetadata.extras
|
||||
?.getString(PlaybackService.EXTRA_AUDIO_URL)
|
||||
if (audioUrl != null) {
|
||||
val videoSource = progFactory.createMediaSource(mediaItem)
|
||||
val audioSource = progFactory.createMediaSource(MediaItem.fromUri(Uri.parse(audioUrl)))
|
||||
return MergingMediaSource(videoSource, audioSource)
|
||||
}
|
||||
val mime = mediaItem.localConfiguration?.mimeType
|
||||
return when (mime) {
|
||||
MimeTypes.APPLICATION_MPD -> dashFactory.createMediaSource(mediaItem)
|
||||
MimeTypes.APPLICATION_M3U8 -> hlsFactory.createMediaSource(mediaItem)
|
||||
else -> {
|
||||
// Try progressive first; fall back to the default factory's
|
||||
// extractor-based selection so generic URIs (e.g. local
|
||||
// file:// from the downloads dir) still work.
|
||||
runCatching { progFactory.createMediaSource(mediaItem) }
|
||||
.getOrElse { defaultFactory.createMediaSource(mediaItem) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setDrmSessionManagerProvider(p: DrmSessionManagerProvider): MediaSource.Factory {
|
||||
dashFactory.setDrmSessionManagerProvider(p)
|
||||
hlsFactory.setDrmSessionManagerProvider(p)
|
||||
progFactory.setDrmSessionManagerProvider(p)
|
||||
defaultFactory.setDrmSessionManagerProvider(p)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun setLoadErrorHandlingPolicy(p: LoadErrorHandlingPolicy): MediaSource.Factory {
|
||||
dashFactory.setLoadErrorHandlingPolicy(p)
|
||||
hlsFactory.setLoadErrorHandlingPolicy(p)
|
||||
progFactory.setLoadErrorHandlingPolicy(p)
|
||||
defaultFactory.setLoadErrorHandlingPolicy(p)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun getSupportedTypes(): IntArray =
|
||||
intArrayOf(C.CONTENT_TYPE_DASH, C.CONTENT_TYPE_HLS, C.CONTENT_TYPE_OTHER)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,43 +2,58 @@
|
|||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Phase C: Media3 PlayerView embedded in Compose.
|
||||
* Phase D: SponsorBlock auto-skip wired in via position-poll loop.
|
||||
* Fullscreen player surface. The player itself lives in PlaybackService
|
||||
* (one ExoPlayer for the whole app); this composable is a thin shell that
|
||||
* renders a PlayerView bound to the shared MediaController and overlays
|
||||
* speed / audio-only / share / PiP / minimize controls. To minimize, tap
|
||||
* the down-arrow button (top right) — the swipe-down gesture lives on
|
||||
* the VideoDetail page instead, where it doesn't fight PlayerView's own
|
||||
* touch handling. SponsorBlock auto-skip lives at the activity root in
|
||||
* [SponsorBlockSkipLoop].
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.player
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Rational
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.Headphones
|
||||
import androidx.compose.material.icons.filled.PictureInPictureAlt
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.material.icons.filled.Videocam
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -49,29 +64,18 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionParameters
|
||||
import androidx.media3.common.TrackGroup as Media3TrackGroup
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.exoplayer.dash.DashMediaSource
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.MergingMediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.sulkta.straw.extractor.NewPipeDownloader
|
||||
import com.sulkta.straw.OverlayChromeColor
|
||||
import com.sulkta.straw.feature.detail.VideoDetailViewModel
|
||||
import com.sulkta.straw.net.SbSegment
|
||||
import com.sulkta.straw.util.LogDump
|
||||
import com.sulkta.straw.util.strawLogI
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
|
|
@ -80,170 +84,73 @@ import kotlinx.coroutines.delay
|
|||
fun PlayerScreen(
|
||||
streamUrl: String,
|
||||
title: String,
|
||||
vm: PlayerViewModel = viewModel(),
|
||||
onMinimize: () -> Unit = {},
|
||||
vm: VideoDetailViewModel = viewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val controller = LocalStrawController.current
|
||||
val state by vm.ui.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(streamUrl) { vm.resolve(streamUrl) }
|
||||
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
|
||||
|
||||
// Local UI state for speed / audio-only / dialog open.
|
||||
var playbackSpeed by remember { mutableStateOf(1.0f) }
|
||||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||
var audioOnly by remember { mutableStateOf(false) }
|
||||
var showSpeedDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context)
|
||||
.setAudioAttributes(
|
||||
// Tell the system we're playing media so audio focus +
|
||||
// ducking + Bluetooth routing work, and notifications can
|
||||
// sit alongside other media apps.
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true,
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
// Wrap the player in a MediaSession so the OS gets lock-screen +
|
||||
// notification media controls while this Activity is alive. Full
|
||||
// background-audio-after-Activity-kill is M-3 (MediaSessionService +
|
||||
// MediaController refactor).
|
||||
val mediaSession = remember {
|
||||
MediaSession.Builder(context, exoPlayer).build()
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
mediaSession.release()
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
// PiP setup: on Android 12+ tell the OS this activity can auto-enter
|
||||
// PiP, so when the user presses Home or swipes away the video shrinks
|
||||
// into a floating window instead of pausing/exiting. Aspect ratio is
|
||||
// set eagerly so the system can sample it before the first transition.
|
||||
val activity = context as? Activity
|
||||
DisposableEffect(activity) {
|
||||
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val params = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(16, 9))
|
||||
.setAutoEnterEnabled(true)
|
||||
.build()
|
||||
runCatching { activity.setPictureInPictureParams(params) }
|
||||
}
|
||||
onDispose {
|
||||
// Disable auto-enter when leaving the player so the rest of the
|
||||
// app doesn't accidentally PiP on background.
|
||||
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val off = PictureInPictureParams.Builder()
|
||||
.setAutoEnterEnabled(false)
|
||||
.build()
|
||||
runCatching { activity.setPictureInPictureParams(off) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AUD-MED: pause playback when app goes to background. Without this,
|
||||
// ExoPlayer keeps playing audio with no MediaSession — user can't pause
|
||||
// from the notification shade. EXCEPTION: don't pause when entering
|
||||
// Picture-in-Picture mode (that's the whole point of PiP).
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
val activity = context as? Activity
|
||||
if (activity?.isInPictureInPictureMode != true) {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
// When the resolved playback for this URL is ready, push it into the
|
||||
// shared controller — unless it's already playing this exact URL, in
|
||||
// which case do nothing: the player is already where we want it. The
|
||||
// previous "seek-to-self" path here was always a few ms backwards and
|
||||
// produced a jerk on every entry; the controller's currentPosition is
|
||||
// its own source of truth.
|
||||
val resolved = state.resolved
|
||||
|
||||
LaunchedEffect(resolved) {
|
||||
val detail = state.detail
|
||||
LaunchedEffect(controller, resolved, detail) {
|
||||
val c = controller ?: return@LaunchedEffect
|
||||
val r = resolved ?: return@LaunchedEffect
|
||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
.setUserAgent(NewPipeDownloader.USER_AGENT)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
|
||||
val source = when {
|
||||
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.dashMpdUrl))
|
||||
|
||||
r.hlsUrl != null -> HlsMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.hlsUrl))
|
||||
|
||||
r.combinedUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.combinedUrl))
|
||||
|
||||
r.videoUrl != null && r.audioUrl != null -> {
|
||||
val v = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.videoUrl))
|
||||
val a = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.audioUrl))
|
||||
MergingMediaSource(v, a)
|
||||
}
|
||||
|
||||
r.videoUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.videoUrl))
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (source != null) {
|
||||
exoPlayer.setMediaSource(source)
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.playWhenReady = true
|
||||
}
|
||||
val uploader = detail?.uploader.orEmpty()
|
||||
val thumbnail = detail?.thumbnail
|
||||
// Optimization, not safety. claim() guards the race.
|
||||
if (NowPlaying.current.value?.streamUrl == streamUrl) return@LaunchedEffect
|
||||
c.setPlayingFrom(
|
||||
streamUrl = streamUrl,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
thumbnail = thumbnail,
|
||||
resolved = r,
|
||||
uploaderUrl = detail?.uploaderUrl,
|
||||
)
|
||||
}
|
||||
|
||||
// SponsorBlock auto-skip — poll position every 150ms, seek past any segment.
|
||||
// AUD-HIGH fixes vs initial impl:
|
||||
// - dedup skipped segments via UUID so re-listen doesn't fight the user
|
||||
// - tighter poll (150ms) reduces sponsor leak through buffering window
|
||||
// - check playbackState != IDLE/ENDED (was isPlaying, which is false
|
||||
// during buffering and missed the skip window)
|
||||
// - clamp seek target away from duration boundary to avoid jank
|
||||
val skippedUuids = remember { mutableSetOf<String>() }
|
||||
LaunchedEffect(resolved?.segments) {
|
||||
val segments = resolved?.segments ?: return@LaunchedEffect
|
||||
if (segments.isEmpty()) return@LaunchedEffect
|
||||
skippedUuids.clear()
|
||||
while (true) {
|
||||
delay(150)
|
||||
val state = exoPlayer.playbackState
|
||||
if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) continue
|
||||
val posSec = exoPlayer.currentPosition / 1000.0
|
||||
val segment = pickActiveSegment(segments, posSec, skippedUuids) ?: continue
|
||||
strawLogI(
|
||||
"StrawSb",
|
||||
"skip: ${segment.category} ${segment.startSec}s..${segment.endSec}s (pos=$posSec)",
|
||||
)
|
||||
val targetMs = (segment.endSec * 1000).toLong()
|
||||
val durationMs = exoPlayer.duration
|
||||
if (durationMs > 0 && targetMs >= durationMs - 500) {
|
||||
// Past end — let it end naturally rather than seeking past content.
|
||||
exoPlayer.seekTo(durationMs - 1)
|
||||
} else {
|
||||
exoPlayer.seekTo(targetMs)
|
||||
// Surface ExoPlayer failures from the service into the UI.
|
||||
var playbackError by remember { mutableStateOf<String?>(null) }
|
||||
DisposableEffect(controller) {
|
||||
val c = controller
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
// Scrub the message before rendering. Media3's
|
||||
// HttpDataSource exceptions embed the full request URI
|
||||
// (with signature= / pot= / cpn=) in the .message
|
||||
// string — visible in the on-screen error banner and
|
||||
// a screenshot away from being shared.
|
||||
val raw = error.message ?: "(no message)"
|
||||
playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
|
||||
// Also clear NowPlaying so the minibar doesn't keep
|
||||
// claiming a dead session is loaded.
|
||||
NowPlaying.clear()
|
||||
}
|
||||
segment.UUID?.let { skippedUuids.add(it) }
|
||||
Toast.makeText(context, "skipped ${segment.category}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
c?.addListener(listener)
|
||||
onDispose { c?.removeListener(listener) }
|
||||
}
|
||||
|
||||
val activity = context as? Activity
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
state.loading -> CircularProgressIndicator()
|
||||
state.loading || controller == null -> CircularProgressIndicator()
|
||||
|
||||
state.error != null -> Text(
|
||||
"playback error: ${state.error}",
|
||||
|
|
@ -251,52 +158,83 @@ fun PlayerScreen(
|
|||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
playbackError != null -> Text(
|
||||
"playback error: $playbackError",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
resolved?.isPlayable != true -> Text(
|
||||
"no playable stream found",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
else -> {
|
||||
// Video surface — bleeds full-screen including under any
|
||||
// display cutout / camera notch. Looks more immersive.
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
player = exoPlayer
|
||||
player = controller
|
||||
useController = true
|
||||
// Keep the last frame on screen when this
|
||||
// view's player is reset (fullscreen →
|
||||
// inline transition). Without this, the
|
||||
// detaching PlayerView flashes black for
|
||||
// ~1 frame before the receiving view takes
|
||||
// over the surface.
|
||||
controllerHideOnTouch = true
|
||||
setKeepContentOnPlayerReset(true)
|
||||
// Don't let the device timeout/lock while
|
||||
// a fullscreen video is on-screen. View-
|
||||
// level flag — propagates to the window
|
||||
// while attached, clears on detach so
|
||||
// backing out of fullscreen releases the
|
||||
// wake-lock automatically. Mirror on the
|
||||
// inline PlayerView for consistency.
|
||||
keepScreenOn = true
|
||||
}
|
||||
},
|
||||
update = { it.player = controller },
|
||||
onRelease = { it.player = null },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
// SponsorBlock segment count badge — small overlay top-left.
|
||||
resolved?.let { r ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(12.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color(0xCC222222))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "SB: ${r.segments.size} segment${if (r.segments.size == 1) "" else "s"}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
// Overlay controls layer — sits inside the safe area so
|
||||
// buttons don't get eaten by the notch in portrait or by
|
||||
// a side cutout in landscape. SafeDrawing covers system
|
||||
// bars + display cutouts in one go.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(12.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(OverlayChromeColor)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "SB: ${resolved.segments.size} segment${if (resolved.segments.size == 1) "" else "s"}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
// Top-right overlay — speed / audio-only / share / PiP.
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Playback speed
|
||||
OverlayButton(label = if (playbackSpeed == 1f) "1×" else "${playbackSpeed}×") {
|
||||
OverlayIconButton(icon = Icons.Filled.Speed, desc = "Playback speed") {
|
||||
showSpeedDialog = true
|
||||
}
|
||||
// Audio-only toggle
|
||||
OverlayButton(label = if (audioOnly) "📻" else "📺") {
|
||||
OverlayIconButton(
|
||||
icon = if (audioOnly) Icons.Filled.Headphones else Icons.Filled.Videocam,
|
||||
desc = if (audioOnly) "Audio-only on" else "Video on",
|
||||
) {
|
||||
audioOnly = !audioOnly
|
||||
// Disable / enable video renderer via track-selection params.
|
||||
exoPlayer.trackSelectionParameters = TrackSelectionParameters.Builder(context)
|
||||
controller.trackSelectionParameters = TrackSelectionParameters.Builder(context)
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, audioOnly)
|
||||
.build()
|
||||
Toast.makeText(
|
||||
|
|
@ -305,8 +243,7 @@ fun PlayerScreen(
|
|||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
// Share
|
||||
OverlayButton(label = "↗") {
|
||||
OverlayIconButton(icon = Icons.Filled.Share, desc = "Share") {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, streamUrl)
|
||||
|
|
@ -314,72 +251,38 @@ fun PlayerScreen(
|
|||
}
|
||||
context.startActivity(Intent.createChooser(send, "Share video"))
|
||||
}
|
||||
// PiP — manual entry (auto-enter on home gesture is wired
|
||||
// up via the DisposableEffect above on Android 12+).
|
||||
OverlayButton(label = "⊟") {
|
||||
val act = (context as? Activity)
|
||||
if (act == null) {
|
||||
OverlayIconButton(icon = Icons.Filled.PictureInPictureAlt, desc = "Picture in picture") {
|
||||
if (activity == null) {
|
||||
Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show()
|
||||
return@OverlayButton
|
||||
return@OverlayIconButton
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show()
|
||||
return@OverlayButton
|
||||
return@OverlayIconButton
|
||||
}
|
||||
val params = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(16, 9))
|
||||
.build()
|
||||
val result = runCatching { act.enterPictureInPictureMode(params) }
|
||||
result.onSuccess { ok ->
|
||||
if (!ok) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"PiP refused — check Settings > Apps > Straw > PiP",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
runCatching { activity.enterPictureInPictureMode(params) }
|
||||
.onSuccess { ok ->
|
||||
if (!ok) Toast.makeText(context, "PiP refused", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
.onFailure { t ->
|
||||
Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
result.onFailure { t ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"PiP failed: ${t.message ?: t.javaClass.simpleName}",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
// Background audio (phase S) — independent foreground-service playback.
|
||||
// Audit HIGH-1: handing off, not dual-hosting. Stop activity's player
|
||||
// first so the OS sees a single MediaSession (cleaner lockscreen +
|
||||
// audio focus) and we don't leak two active ExoPlayers.
|
||||
OverlayButton(label = "🎧") {
|
||||
val r = resolved ?: return@OverlayButton
|
||||
val audio = r.audioUrl ?: r.combinedUrl
|
||||
if (audio == null) {
|
||||
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
|
||||
return@OverlayButton
|
||||
}
|
||||
runCatching { exoPlayer.stop() }
|
||||
runCatching { exoPlayer.clearMediaItems() }
|
||||
val intent = Intent(context, PlaybackService::class.java).apply {
|
||||
component = ComponentName(context, PlaybackService::class.java)
|
||||
putExtra(PlaybackService.EXTRA_URL, audio)
|
||||
putExtra(PlaybackService.EXTRA_TITLE, title)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
Toast.makeText(
|
||||
context,
|
||||
"background audio started — close the app whenever",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
OverlayIconButton(icon = Icons.Filled.KeyboardArrowDown, desc = "Minimize") {
|
||||
onMinimize()
|
||||
}
|
||||
}
|
||||
} // close safe-area overlay Box
|
||||
|
||||
if (showSpeedDialog) {
|
||||
SpeedPickerDialog(
|
||||
current = playbackSpeed,
|
||||
onPick = { s ->
|
||||
playbackSpeed = s
|
||||
exoPlayer.playbackParameters = PlaybackParameters(s)
|
||||
controller.playbackParameters = PlaybackParameters(s)
|
||||
showSpeedDialog = false
|
||||
},
|
||||
onDismiss = { showSpeedDialog = false },
|
||||
|
|
@ -391,16 +294,25 @@ fun PlayerScreen(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun OverlayButton(label: String, onClick: () -> Unit) {
|
||||
private fun OverlayIconButton(
|
||||
icon: ImageVector,
|
||||
desc: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color(0xCC222222))
|
||||
.background(OverlayChromeColor)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, color = Color.White, style = MaterialTheme.typography.titleSmall)
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = desc,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -419,7 +331,7 @@ private fun SpeedPickerDialog(
|
|||
options.forEach { s ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.fillMaxWidth()
|
||||
.clickable { onPick(s) }
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -441,9 +353,63 @@ private fun SpeedPickerDialog(
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the segment whose interval contains [posSec], if any, skipping
|
||||
* UUIDs in [skipped]. Filters out POI-style point segments (start == end).
|
||||
* SponsorBlock skip loop driven by the controller's currentPosition.
|
||||
* Lives at the activity composition root so it skips segments whether
|
||||
* the user is fullscreen, in the minibar, or away from the player
|
||||
* surface.
|
||||
*
|
||||
* The `skipped` set is only mutated from this single coroutine — safe
|
||||
* without synchronization while that invariant holds.
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(UnstableApi::class)
|
||||
fun SponsorBlockSkipLoop() {
|
||||
val controller = LocalStrawController.current
|
||||
val context = LocalContext.current
|
||||
val item by NowPlaying.current.collectAsStateWithLifecycle()
|
||||
val cur = item ?: return
|
||||
val segments = cur.segments
|
||||
if (segments.isEmpty() || controller == null) return
|
||||
val skipped = remember(cur.streamUrl) { mutableSetOf<String>() }
|
||||
// Rate-limit the skip Toast — back-to-back segments in
|
||||
// sponsor-dense videos used to queue 20+ Toasts that paint over
|
||||
// the screen for 40s after the actual seek ( audit HIGH-B7).
|
||||
var lastToastAt by remember(cur.streamUrl) { mutableStateOf(0L) }
|
||||
LaunchedEffect(cur.streamUrl, controller) {
|
||||
while (true) {
|
||||
delay(150)
|
||||
val state = controller.playbackState
|
||||
if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) continue
|
||||
// Skip the position read + segment scan when not actively
|
||||
// playing — on a paused-overnight session the prior shape
|
||||
// hit the binder every 150ms for hours.
|
||||
if (!controller.isPlaying) {
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
val posSec = controller.currentPosition / 1000.0
|
||||
val s = pickActiveSegment(segments, posSec, skipped) ?: continue
|
||||
strawLogI(
|
||||
"StrawSb",
|
||||
"skip: ${s.category} ${s.startSec}s..${s.endSec}s (pos=$posSec)",
|
||||
)
|
||||
val targetMs = (s.endSec * 1000).toLong()
|
||||
val durationMs = controller.duration
|
||||
if (durationMs > 0 && targetMs >= durationMs - 500) {
|
||||
controller.seekTo(durationMs - 1)
|
||||
} else {
|
||||
controller.seekTo(targetMs)
|
||||
}
|
||||
s.UUID?.let { skipped.add(it) }
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastToastAt > 3000) {
|
||||
Toast.makeText(context, "skipped ${s.category}", Toast.LENGTH_SHORT).show()
|
||||
lastToastAt = now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickActiveSegment(
|
||||
segments: List<SbSegment>,
|
||||
posSec: Double,
|
||||
|
|
@ -451,5 +417,8 @@ private fun pickActiveSegment(
|
|||
): SbSegment? = segments.firstOrNull { s ->
|
||||
val uuidNotSkipped = s.UUID == null || s.UUID !in skipped
|
||||
val interval = s.endSec - s.startSec > 0.1
|
||||
uuidNotSkipped && interval && posSec >= s.startSec && posSec < s.endSec - 0.05
|
||||
// Drop the prior -0.05 exclusion — combined with the loop's
|
||||
// 150ms polling cadence, short SB segments could fall in the
|
||||
// gap and silently skip the skip.
|
||||
uuidNotSkipped && interval && posSec >= s.startSec && posSec < s.endSec
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.player
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.sulkta.straw.data.MaxResolution
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.net.SbSegment
|
||||
import com.sulkta.straw.net.SponsorBlockClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo
|
||||
|
||||
data class ResolvedPlayback(
|
||||
val title: String,
|
||||
val videoUrl: String?,
|
||||
val audioUrl: String?,
|
||||
val combinedUrl: String?,
|
||||
val dashMpdUrl: String?,
|
||||
val hlsUrl: String?,
|
||||
val segments: List<SbSegment> = emptyList(),
|
||||
) {
|
||||
/** Have anything playable? */
|
||||
val isPlayable: Boolean
|
||||
get() = !combinedUrl.isNullOrBlank() || !videoUrl.isNullOrBlank() ||
|
||||
!dashMpdUrl.isNullOrBlank() || !hlsUrl.isNullOrBlank()
|
||||
}
|
||||
|
||||
data class PlayerUiState(
|
||||
val loading: Boolean = true,
|
||||
val resolved: ResolvedPlayback? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PlayerViewModel : ViewModel() {
|
||||
private val _ui = MutableStateFlow(PlayerUiState())
|
||||
val ui: StateFlow<PlayerUiState> = _ui.asStateFlow()
|
||||
|
||||
fun resolve(streamUrl: String) {
|
||||
_ui.value = PlayerUiState(loading = true)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
|
||||
val videoId = info.id
|
||||
val sbCategories = Settings.get().sbCategories.value.map { it.key }
|
||||
val segments = if (sbCategories.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { SponsorBlockClient.fetch(videoId, sbCategories) }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
val maxRes = Settings.get().maxResolution.value.ceiling
|
||||
fun heightOf(q: String?): Int =
|
||||
q?.removeSuffix("p")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0
|
||||
|
||||
// Audit HIGH-8: when no stream is under the resolution ceiling
|
||||
// (e.g. user picked 144p but the video only has 360p+), fall
|
||||
// back to the lowest-resolution available instead of returning
|
||||
// null and showing a black-screen player.
|
||||
fun pickVideo(streams: List<org.schabi.newpipe.extractor.stream.VideoStream>?): String? {
|
||||
if (streams.isNullOrEmpty()) return null
|
||||
val withContent = streams.filter { it.content?.isNotBlank() == true }
|
||||
val filtered = withContent.filter { heightOf(it.getResolution()) <= maxRes }
|
||||
val pool = filtered.ifEmpty { withContent }
|
||||
return pool.maxByOrNull { it.bitrate ?: 0 }?.content
|
||||
}
|
||||
|
||||
val combined = pickVideo(info.videoStreams)
|
||||
val videoOnly = pickVideo(info.videoOnlyStreams)
|
||||
val audioOnly = info.audioStreams
|
||||
?.filter { it.content?.isNotBlank() == true }
|
||||
?.maxByOrNull { it.bitrate ?: 0 }
|
||||
?.content
|
||||
|
||||
_ui.value = PlayerUiState(
|
||||
loading = false,
|
||||
resolved = ResolvedPlayback(
|
||||
title = info.name ?: "",
|
||||
videoUrl = videoOnly,
|
||||
audioUrl = audioOnly,
|
||||
combinedUrl = combined,
|
||||
dashMpdUrl = info.dashMpdUrl?.takeIf { it.isNotBlank() },
|
||||
hlsUrl = info.hlsUrl?.takeIf { it.isNotBlank() },
|
||||
segments = segments,
|
||||
),
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
_ui.value = PlayerUiState(
|
||||
loading = false,
|
||||
error = t.message ?: t.javaClass.simpleName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Process-wide upcoming-videos queue. Mirrors the MediaController's
|
||||
* MediaItem list 1:1 by index — Position 0 is "currently playing",
|
||||
* Position 1+ is "up next". Decoupled from the controller because:
|
||||
*
|
||||
* - The controller stores MediaItem (URL + Media3 metadata only).
|
||||
* We need the original streamUrl, uploader, thumbnail, and
|
||||
* SponsorBlock segments. NowPlayingItem carries all of that.
|
||||
* - Media3's onMediaItemTransition fires when the engine auto-
|
||||
* advances. PlaybackService listens, looks up the new index here,
|
||||
* and pushes the resolved NowPlayingItem into NowPlaying so the
|
||||
* minibar + SponsorBlock skip-loop reflect the new track.
|
||||
*
|
||||
* Append-only + setAll: no remove/reorder for v1. Mirrors how
|
||||
* `addMediaItem` / `setMediaItem` mutate the controller. If we ever
|
||||
* add a queue UI with drag-reorder, that'll need a sync layer.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.player
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
object Queue {
|
||||
private val _items = MutableStateFlow<List<NowPlayingItem>>(emptyList())
|
||||
val items: StateFlow<List<NowPlayingItem>> = _items.asStateFlow()
|
||||
|
||||
/** Replace the queue — used by setPlayingFrom when starting fresh. */
|
||||
fun setAll(item: NowPlayingItem) {
|
||||
_items.value = listOf(item)
|
||||
}
|
||||
|
||||
fun append(item: NowPlayingItem) {
|
||||
_items.update { it + item }
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert at the given position (relative to the controller's
|
||||
* indices). Used by "Play next" — inserts right after the
|
||||
* currently-playing item.
|
||||
*/
|
||||
fun insertAt(index: Int, item: NowPlayingItem) {
|
||||
_items.update { current ->
|
||||
val mut = current.toMutableList()
|
||||
mut.add(index.coerceIn(0, mut.size), item)
|
||||
mut.toList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the item at the given controller index, or null on OOB. */
|
||||
fun at(index: Int): NowPlayingItem? = _items.value.getOrNull(index)
|
||||
|
||||
fun clear() {
|
||||
_items.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Composable bridge to the PlaybackService MediaController.
|
||||
*
|
||||
* Why this file exists: every UI surface in Straw (inline player on the
|
||||
* detail screen, the fullscreen Player, the minibar overlay) renders the
|
||||
* same single underlying MediaController. We expose it via a
|
||||
* CompositionLocal so the screens don't have to know how to connect.
|
||||
*
|
||||
* The controller is built async — SessionToken bind happens on a
|
||||
* background thread, the controller future resolves once the service is
|
||||
* up. Until then `LocalStrawController.current` is null; consumers
|
||||
* should render placeholder UI in that brief window.
|
||||
*
|
||||
* Lifecycle: tied to the activity's composition. When the activity
|
||||
* finishes the DisposableEffect cleanup releases the future. The
|
||||
* MediaSessionService stays alive iff there's still something playing
|
||||
* (its own onTaskRemoved + STATE_ENDED logic handles that).
|
||||
*
|
||||
* Also: a small helper, [setPlayingFrom], that knows how to convert
|
||||
* Straw's domain ResolvedPlayback (DASH URL / HLS URL / combined URL /
|
||||
* video+audio pair) into a single MediaItem the service understands.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.player
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionParameters
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import com.sulkta.straw.data.Resume
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.feature.detail.ResolvedPlayback
|
||||
import com.sulkta.straw.feature.detail.extractYtVideoId
|
||||
|
||||
val LocalStrawController = compositionLocalOf<MediaController?> { null }
|
||||
|
||||
@Composable
|
||||
fun rememberStrawController(): MediaController? {
|
||||
val context = LocalContext.current
|
||||
val state = remember { mutableStateOf<MediaController?>(null) }
|
||||
DisposableEffect(Unit) {
|
||||
val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
val future = MediaController.Builder(context, token).buildAsync()
|
||||
future.addListener({
|
||||
// future.get() throws if the build failed; treat as null in that case.
|
||||
state.value = runCatching { future.get() }.getOrNull()
|
||||
}, MoreExecutors.directExecutor())
|
||||
onDispose {
|
||||
MediaController.releaseFuture(future)
|
||||
state.value = null
|
||||
}
|
||||
}
|
||||
return state.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a resolved video into the controller and update NowPlaying.
|
||||
*
|
||||
* Stream-shape preference matches the previous activity-side picker:
|
||||
* DASH (full quality + adaptive) > HLS > combined progressive > merged
|
||||
* video+audio progressives > video-only progressive. The
|
||||
* [StrawMediaSourceFactory] on the service end picks the right inner
|
||||
* MediaSource based on MIME + the EXTRA_AUDIO_URL bundle.
|
||||
*/
|
||||
@UnstableApi
|
||||
fun Player.setPlayingFrom(
|
||||
streamUrl: String,
|
||||
title: String,
|
||||
uploader: String,
|
||||
thumbnail: String?,
|
||||
resolved: ResolvedPlayback,
|
||||
startPositionMs: Long = 0L,
|
||||
uploaderUrl: String? = null,
|
||||
) {
|
||||
val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return
|
||||
val nowPlayingItem = NowPlayingItem(
|
||||
streamUrl = streamUrl,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
uploaderUrl = uploaderUrl,
|
||||
thumbnail = thumbnail,
|
||||
segments = resolved.segments,
|
||||
)
|
||||
// Atomic claim BEFORE any controller mutation. If a concurrent
|
||||
// caller already set this URL (inline player + fullscreen Player
|
||||
// racing each other on the same transition), we bail before
|
||||
// double-priming the player. audit HIGH-C6.
|
||||
val claimed = NowPlaying.claim(nowPlayingItem)
|
||||
if (!claimed) return
|
||||
// Replace the queue when starting fresh — Queue mirrors the
|
||||
// controller's MediaItem list 1:1 by index. If the user later
|
||||
// long-press-enqueues more items, append/insertAt keep them
|
||||
// synced.
|
||||
Queue.setAll(nowPlayingItem)
|
||||
// Apply the user's max-resolution cap to DASH/HLS adaptive
|
||||
// streams. — the cap previously only
|
||||
// affected the videoOnly/combined picker; DASH manifests
|
||||
// bypassed it because Media3 picked variants freely. setMaxVideoSize
|
||||
// tells the ABR algorithm to never pick anything taller than
|
||||
// ceiling. Auto = Int.MAX_VALUE = no constraint.
|
||||
applyMaxResolutionCap()
|
||||
// Auto-resume: when the caller passed the default 0L and
|
||||
// Settings.autoResume is on, look up the saved scrub-point for
|
||||
// this videoId. Lets the user pick up where they left off after
|
||||
// an app update / process death. The store skips trivial
|
||||
// positions and clears near-end so we don't auto-resume to 0:03
|
||||
// or to the credits.
|
||||
//
|
||||
// Clamp the resume position against the RECORDED duration with a
|
||||
// safety margin.: YouTube can replace a video
|
||||
// at the same videoId with a shorter cut (live→VOD trim, premiere
|
||||
// edit, channel replace) — without the clamp, setMediaItem seeks
|
||||
// past the new end, ExoPlayer fires onPlayerError, the screen
|
||||
// ends up stuck on the thumbnail+spinner (BUG-2 cascade).
|
||||
val effectiveStart = if (startPositionMs == 0L && Settings.get().autoResume.value) {
|
||||
val videoId = extractYtVideoId(streamUrl)
|
||||
val saved = videoId?.let { Resume.get().get(it) }
|
||||
if (saved == null) {
|
||||
0L
|
||||
} else {
|
||||
val safeCeiling = saved.durationMs - 5_000L
|
||||
if (saved.positionMs in 1L..safeCeiling) saved.positionMs else 0L
|
||||
}
|
||||
} else {
|
||||
startPositionMs
|
||||
}
|
||||
setMediaItem(mediaItem, effectiveStart)
|
||||
prepare()
|
||||
playWhenReady = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the current Settings.maxResolution into the controller's
|
||||
* TrackSelectionParameters as a height cap. Idempotent — safe to
|
||||
* call repeatedly. Called inside setPlayingFrom so every new
|
||||
* playback respects the live preference; setting changes mid-stream
|
||||
* apply on next video.
|
||||
*/
|
||||
@UnstableApi
|
||||
fun Player.applyMaxResolutionCap() {
|
||||
val ceiling = Settings.get().maxResolution.value.ceiling
|
||||
val maxHeight = if (ceiling >= Int.MAX_VALUE) Int.MAX_VALUE else ceiling
|
||||
trackSelectionParameters = trackSelectionParameters.buildUpon()
|
||||
.setMaxVideoSize(Int.MAX_VALUE, maxHeight)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a video to the playback queue right after the currently-playing
|
||||
* item. If the player is idle (no current item), fall through to a
|
||||
* setPlayingFrom that starts playback immediately. The caller already
|
||||
* resolved playback (strawcore.streamInfo → ResolvedPlayback).
|
||||
*
|
||||
* Returns true if the item was enqueued or started; false on a build
|
||||
* failure (no playable stream in `resolved`).
|
||||
*/
|
||||
@UnstableApi
|
||||
fun Player.enqueueNext(
|
||||
streamUrl: String,
|
||||
title: String,
|
||||
uploader: String,
|
||||
thumbnail: String?,
|
||||
resolved: ResolvedPlayback,
|
||||
uploaderUrl: String? = null,
|
||||
): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, uploaderUrl, asNext = true)
|
||||
|
||||
/** Append to the back of the queue. Same idle-fallback as enqueueNext. */
|
||||
@UnstableApi
|
||||
fun Player.enqueueLast(
|
||||
streamUrl: String,
|
||||
title: String,
|
||||
uploader: String,
|
||||
thumbnail: String?,
|
||||
resolved: ResolvedPlayback,
|
||||
uploaderUrl: String? = null,
|
||||
): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, uploaderUrl, asNext = false)
|
||||
|
||||
@UnstableApi
|
||||
private fun Player.enqueueInternal(
|
||||
streamUrl: String,
|
||||
title: String,
|
||||
uploader: String,
|
||||
thumbnail: String?,
|
||||
resolved: ResolvedPlayback,
|
||||
uploaderUrl: String?,
|
||||
asNext: Boolean,
|
||||
): Boolean {
|
||||
val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return false
|
||||
val item = NowPlayingItem(
|
||||
streamUrl = streamUrl,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
uploaderUrl = uploaderUrl,
|
||||
thumbnail = thumbnail,
|
||||
segments = resolved.segments,
|
||||
)
|
||||
// Empty queue — there's nothing to "enqueue" onto. Treat as a
|
||||
// start-playing-now and route through the normal claim path.
|
||||
if (mediaItemCount == 0) {
|
||||
setPlayingFrom(streamUrl, title, uploader, thumbnail, resolved, uploaderUrl = uploaderUrl)
|
||||
return true
|
||||
}
|
||||
val insertIndex = if (asNext) currentMediaItemIndex + 1 else mediaItemCount
|
||||
Queue.insertAt(insertIndex, item)
|
||||
addMediaItem(insertIndex, mediaItem)
|
||||
return true
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
private fun buildMediaItem(
|
||||
title: String,
|
||||
uploader: String,
|
||||
thumbnail: String?,
|
||||
r: ResolvedPlayback,
|
||||
): MediaItem? {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setArtist(uploader)
|
||||
.apply {
|
||||
thumbnail?.let { setArtworkUri(android.net.Uri.parse(it)) }
|
||||
}
|
||||
.build()
|
||||
val baseBuilder = MediaItem.Builder().setMediaMetadata(metadata)
|
||||
return when {
|
||||
!r.dashMpdUrl.isNullOrBlank() -> baseBuilder
|
||||
.setUri(r.dashMpdUrl)
|
||||
.setMimeType(MimeTypes.APPLICATION_MPD)
|
||||
.build()
|
||||
!r.hlsUrl.isNullOrBlank() -> baseBuilder
|
||||
.setUri(r.hlsUrl)
|
||||
.setMimeType(MimeTypes.APPLICATION_M3U8)
|
||||
.build()
|
||||
!r.combinedUrl.isNullOrBlank() -> baseBuilder
|
||||
.setUri(r.combinedUrl)
|
||||
.build()
|
||||
!r.videoUrl.isNullOrBlank() && !r.audioUrl.isNullOrBlank() -> {
|
||||
val extras = Bundle().apply {
|
||||
putString(PlaybackService.EXTRA_AUDIO_URL, r.audioUrl)
|
||||
}
|
||||
baseBuilder
|
||||
.setUri(r.videoUrl)
|
||||
.setRequestMetadata(
|
||||
MediaItem.RequestMetadata.Builder()
|
||||
.setExtras(extras)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
!r.videoUrl.isNullOrBlank() -> baseBuilder
|
||||
.setUri(r.videoUrl)
|
||||
.build()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Red progress bar painted across the bottom of a video thumbnail when
|
||||
* the user has a saved scrub-point in ResumePositionsStore. Same shape
|
||||
* YouTube + NewPipe use — instantly readable as "you started this."
|
||||
*
|
||||
* Drops into any thumbnail-rendering Box; the caller is responsible for
|
||||
* being inside a Box (so we can align to Bottom). Returns nothing when
|
||||
* the videoId is blank or has no recorded position.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.player
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.sulkta.straw.OverlayDimColor
|
||||
import com.sulkta.straw.ProgressBarFillColor
|
||||
import com.sulkta.straw.ProgressBarTrackColor
|
||||
import com.sulkta.straw.data.Resume
|
||||
import com.sulkta.straw.feature.detail.extractYtVideoId
|
||||
import com.sulkta.straw.util.formatDuration
|
||||
|
||||
/**
|
||||
* Paint a 3dp watch-progress bar across the bottom of the surrounding
|
||||
* Box when ResumePositionsStore has an entry for [videoId]. Silent
|
||||
* no-op when there's no entry — safe to call unconditionally.
|
||||
*
|
||||
* Must be used inside a Box (uses BoxScope.align). Caller's Box sets
|
||||
* the thumbnail size; this composable just overlays the bar.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.ThumbnailProgressOverlay(videoId: String?) {
|
||||
if (videoId.isNullOrBlank()) return
|
||||
// Plain collectAsState — collectAsStateWithLifecycle adds a
|
||||
// DisposableEffect for lifecycle observation per call site, which
|
||||
// adds up across 30 visible LazyColumn rows and contributes to
|
||||
// scroll jank The Lifecycle pause optimization doesn't
|
||||
// matter for a foreground feed that's only collected while the
|
||||
// composable is on screen anyway.
|
||||
//
|
||||
// derivedStateOf isolates each row's
|
||||
// dependency to ONLY its own videoId's entry. Without this, the
|
||||
// 5s captureResumePosition tick re-emits the entire positions
|
||||
// map → every visible thumbnail recomposes. With it, only rows
|
||||
// whose specific entry changed recompose.
|
||||
val positionsFlow = Resume.get().positions
|
||||
val positions by positionsFlow.collectAsState()
|
||||
val entry by remember(videoId) {
|
||||
derivedStateOf { positions[videoId] }
|
||||
}
|
||||
val resolved = entry ?: return
|
||||
if (resolved.durationMs <= 0L) return
|
||||
val fraction = (resolved.positionMs.toFloat() / resolved.durationMs.toFloat())
|
||||
.coerceIn(0f, 1f)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.height(3.dp)
|
||||
.background(ProgressBarTrackColor),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(fraction)
|
||||
.background(ProgressBarFillColor),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-stop video thumbnail: 16:9 image with optional NewPipe-style
|
||||
* duration pill at bottom-right + watch-progress overlay at bottom
|
||||
* when the user has a saved scrub-point for [videoUrl].
|
||||
*
|
||||
* Pass an outer modifier with the desired width/height; the corner
|
||||
* radius + clip are applied inside so the progress bar bleeds to the
|
||||
* exact edge of the rounded thumbnail. durationSeconds <= 0 drops the
|
||||
* badge (live streams, items that come back without a duration).
|
||||
*/
|
||||
@Composable
|
||||
fun VideoThumbnail(
|
||||
thumbnail: String?,
|
||||
videoUrl: String?,
|
||||
durationSeconds: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.clip(RoundedCornerShape(6.dp))) {
|
||||
AsyncImage(
|
||||
model = thumbnail,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
if (durationSeconds > 0) {
|
||||
Text(
|
||||
text = formatDuration(durationSeconds),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(4.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(OverlayDimColor)
|
||||
.padding(horizontal = 4.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
ThumbnailProgressOverlay(videoUrl?.let { extractYtVideoId(it) })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Two-screen unit for local playlists:
|
||||
* PlaylistsScreen — root list of all user playlists (drawer entry)
|
||||
* PlaylistViewScreen — items inside one playlist, tap to open
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.playlist
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.sulkta.straw.feature.player.VideoThumbnail
|
||||
import com.sulkta.straw.data.Playlists
|
||||
import com.sulkta.straw.util.rememberBottomContentPadding
|
||||
|
||||
@Composable
|
||||
fun PlaylistsScreen(
|
||||
onOpenPlaylist: (id: String, name: String) -> Unit,
|
||||
) {
|
||||
val store = Playlists.get()
|
||||
val playlists by store.playlists.collectAsState()
|
||||
var showCreate by remember { mutableStateOf(false) }
|
||||
var newName by remember { mutableStateOf("") }
|
||||
var pendingDelete by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Playlists",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Button(onClick = { showCreate = true; newName = "" }) { Text("+ New") }
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"${playlists.size} playlist${if (playlists.size == 1) "" else "s"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (playlists.isEmpty()) {
|
||||
Text(
|
||||
"No playlists yet. Tap + New, or use the Save button on a video.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(contentPadding = rememberBottomContentPadding()) {
|
||||
items(playlists, key = { it.id }) { pl ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenPlaylist(pl.id, pl.name) }
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(56.dp)
|
||||
.height(56.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("📃")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
pl.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"${pl.items.size} video${if (pl.items.size == 1) "" else "s"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = { pendingDelete = pl.id }) {
|
||||
Text("Delete")
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreate) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCreate = false },
|
||||
title = { Text("New playlist") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
store.create(newName)
|
||||
showCreate = false
|
||||
}) { Text("Create") }
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(onClick = { showCreate = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pendingDelete?.let { id ->
|
||||
val name = store.get(id)?.name ?: "this playlist"
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDelete = null },
|
||||
title = { Text("Delete \"$name\"?") },
|
||||
text = { Text("This removes the playlist and its saved video references. Doesn't delete any downloaded files.") },
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
store.delete(id)
|
||||
pendingDelete = null
|
||||
}) { Text("Delete") }
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(onClick = { pendingDelete = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlaylistViewScreen(
|
||||
playlistId: String,
|
||||
initialName: String,
|
||||
onOpenVideo: (url: String, title: String) -> Unit,
|
||||
) {
|
||||
val store = Playlists.get()
|
||||
val playlists by store.playlists.collectAsState()
|
||||
val playlist = playlists.firstOrNull { it.id == playlistId }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
playlist?.name ?: initialName,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
if (playlist == null) {
|
||||
Text(
|
||||
"Playlist not found.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
Text(
|
||||
"${playlist.items.size} video${if (playlist.items.size == 1) "" else "s"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (playlist.items.isEmpty()) {
|
||||
Text(
|
||||
"Empty. Tap Save on a video to add it.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(contentPadding = rememberBottomContentPadding()) {
|
||||
items(playlist.items, key = { it.streamUrl }) { item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenVideo(item.streamUrl, item.title) }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
VideoThumbnail(
|
||||
thumbnail = item.thumbnail,
|
||||
videoUrl = item.streamUrl,
|
||||
durationSeconds = 0L,
|
||||
modifier = Modifier
|
||||
.width(140.dp)
|
||||
.height(80.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
item.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
item.uploader,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
androidx.compose.material3.TextButton(onClick = {
|
||||
store.removeItem(playlist.id, item.streamUrl)
|
||||
}) { Text("×") }
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Shared long-press actions surface for video rows. The menu shows
|
||||
* "Save to playlist" + "Share" (and Add-to-Queue later when the queue
|
||||
* substrate lands). Every video row in the app — Search results,
|
||||
* Subs feed, Channel videos, History, Related — calls
|
||||
* `showVideoActions(...)` from a `combinedClickable.onLongClick`.
|
||||
*
|
||||
* Pure-Compose surface — no ViewModel needed; PlaylistsStore is a
|
||||
* process-wide singleton and the share Intent is a fire-and-forget
|
||||
* Android system action.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.playlist
|
||||
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.filled.PlaylistPlay
|
||||
import androidx.compose.material.icons.filled.QueueMusic
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.sulkta.straw.StrawApp
|
||||
import com.sulkta.straw.data.PlaylistItem
|
||||
import com.sulkta.straw.data.Playlists
|
||||
import com.sulkta.straw.feature.detail.resolveStreamPlayback
|
||||
import com.sulkta.straw.feature.player.LocalStrawController
|
||||
import com.sulkta.straw.feature.player.enqueueLast
|
||||
import com.sulkta.straw.feature.player.enqueueNext
|
||||
import com.sulkta.straw.util.runCatchingCancellable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Minimal video descriptor for the actions sheet. Avoids dragging
|
||||
* the full search.StreamItem (which has extractor fields the
|
||||
* actions don't need) so the same surface can be invoked from
|
||||
* history rows where we only have a WatchHistoryItem.
|
||||
*/
|
||||
data class VideoActionTarget(
|
||||
val streamUrl: String,
|
||||
val title: String,
|
||||
val uploader: String,
|
||||
val thumbnail: String?,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, UnstableApi::class)
|
||||
@Composable
|
||||
fun VideoActionsSheet(
|
||||
target: VideoActionTarget,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val controller = LocalStrawController.current
|
||||
// Use the process scope — rememberCoroutineScope dies when the
|
||||
// sheet dismisses, and we dismiss the sheet BEFORE the strawcore
|
||||
// network round-trip completes (so the user gets immediate
|
||||
// feedback). Process scope keeps the in-flight resolve alive.
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
var showSaveDialog by remember { mutableStateOf(false) }
|
||||
|
||||
/** Resolve a streamUrl → ResolvedPlayback + call the supplied
|
||||
* enqueue method. Network resolution is the slow part; wrap it
|
||||
* in runCatchingCancellable so the rememberCoroutineScope dying
|
||||
* on sheet-dismiss propagates cleanly. */
|
||||
fun enqueue(asNext: Boolean) {
|
||||
val c = controller
|
||||
if (c == null) {
|
||||
Toast.makeText(context, "player not ready yet", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
// The action-sheet bypasses VideoDetailViewModel.load's
|
||||
// allowlist gate — a long-press on a
|
||||
// poisoned related-card otherwise hits strawcore directly.
|
||||
if (!com.sulkta.straw.util.isAllowedYtUrl(target.streamUrl)) {
|
||||
Toast.makeText(context, "unsupported URL", Toast.LENGTH_SHORT).show()
|
||||
onDismiss()
|
||||
return
|
||||
}
|
||||
Toast.makeText(context, "Resolving…", Toast.LENGTH_SHORT).show()
|
||||
val appContext = context.applicationContext
|
||||
onDismiss()
|
||||
StrawApp.globalScope.launch {
|
||||
runCatchingCancellable {
|
||||
val info = uniffi.strawcore.streamInfo(target.streamUrl)
|
||||
val resolved = resolveStreamPlayback(info)
|
||||
withContext(Dispatchers.Main) {
|
||||
val ok = if (asNext) {
|
||||
c.enqueueNext(target.streamUrl, target.title, target.uploader, target.thumbnail, resolved)
|
||||
} else {
|
||||
c.enqueueLast(target.streamUrl, target.title, target.uploader, target.thumbnail, resolved)
|
||||
}
|
||||
val msg = if (ok) {
|
||||
if (asNext) "Will play next" else "Added to queue"
|
||||
} else {
|
||||
"no playable stream"
|
||||
}
|
||||
Toast.makeText(appContext, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(appContext, "queue failed", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSaveDialog) {
|
||||
SaveToPlaylistDialog(
|
||||
item = PlaylistItem(
|
||||
streamUrl = target.streamUrl,
|
||||
title = target.title,
|
||||
thumbnail = target.thumbnail,
|
||||
uploader = target.uploader,
|
||||
),
|
||||
onDismiss = {
|
||||
showSaveDialog = false
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
|
||||
// Title row — truncated to one line, gives context for the
|
||||
// actions below.
|
||||
Text(
|
||||
text = target.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
if (target.uploader.isNotBlank()) {
|
||||
Text(
|
||||
text = target.uploader,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 0.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
ActionRow(
|
||||
icon = Icons.Filled.PlaylistPlay,
|
||||
label = "Play next",
|
||||
onClick = { enqueue(asNext = true) },
|
||||
)
|
||||
ActionRow(
|
||||
icon = Icons.Filled.QueueMusic,
|
||||
label = "Add to queue",
|
||||
onClick = { enqueue(asNext = false) },
|
||||
)
|
||||
ActionRow(
|
||||
icon = Icons.Filled.PlaylistAdd,
|
||||
label = "Save to playlist",
|
||||
onClick = { showSaveDialog = true },
|
||||
)
|
||||
ActionRow(
|
||||
icon = Icons.Filled.Share,
|
||||
label = "Share",
|
||||
onClick = {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, target.streamUrl)
|
||||
putExtra(Intent.EXTRA_SUBJECT, target.title)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(send, "Share video").addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK,
|
||||
),
|
||||
)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(text = label, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "Save to playlist" dialog — was previously inline in
|
||||
* VideoDetailScreen; promoted to its own file so the long-press
|
||||
* menu on any row can reuse it.
|
||||
*/
|
||||
@Composable
|
||||
fun SaveToPlaylistDialog(
|
||||
item: PlaylistItem,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val store = Playlists.get()
|
||||
val playlists by store.playlists.collectAsState()
|
||||
var creatingNew by remember { mutableStateOf(false) }
|
||||
var newName by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Save to playlist") },
|
||||
text = {
|
||||
Column {
|
||||
if (playlists.isEmpty() && !creatingNew) {
|
||||
Text(
|
||||
"No playlists yet. Create one to save this video.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
playlists.forEach { pl ->
|
||||
val already = pl.items.any { it.streamUrl == item.streamUrl }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = !already) {
|
||||
store.addItem(pl.id, item)
|
||||
Toast.makeText(context, "saved to ${pl.name}", Toast.LENGTH_SHORT).show()
|
||||
onDismiss()
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(if (already) "✓" else "○", modifier = Modifier.width(28.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(pl.name, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"${pl.items.size} video${if (pl.items.size == 1) "" else "s"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
if (creatingNew) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
label = { Text("New playlist name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
val pl = store.create(newName)
|
||||
store.addItem(pl.id, item)
|
||||
Toast.makeText(context, "created ${pl.name} + saved", Toast.LENGTH_SHORT).show()
|
||||
onDismiss()
|
||||
}) { Text("Create + save") }
|
||||
OutlinedButton(onClick = { creatingNew = false; newName = "" }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = { creatingNew = true }) {
|
||||
Text("+ New playlist")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Close") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -5,7 +5,9 @@
|
|||
|
||||
package com.sulkta.straw.feature.search
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -43,17 +45,30 @@ import androidx.compose.runtime.collectAsState
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import com.sulkta.straw.feature.player.VideoThumbnail
|
||||
import com.sulkta.straw.data.History
|
||||
import com.sulkta.straw.feature.playlist.VideoActionTarget
|
||||
import com.sulkta.straw.feature.playlist.VideoActionsSheet
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.sulkta.straw.util.formatDuration
|
||||
import com.sulkta.straw.util.formatViews
|
||||
import com.sulkta.straw.util.rememberBottomContentPadding
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
onOpenVideo: (url: String, title: String) -> Unit,
|
||||
onOpenChannel: (url: String, name: String) -> Unit,
|
||||
vm: SearchViewModel = viewModel(),
|
||||
) {
|
||||
val state by vm.ui.collectAsStateWithLifecycle()
|
||||
val recentSearches by History.get().searches.collectAsState()
|
||||
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
|
||||
actionTarget?.let { target ->
|
||||
VideoActionsSheet(target = target, onDismiss = { actionTarget = null })
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().statusBarsPadding().padding(16.dp)) {
|
||||
OutlinedTextField(
|
||||
|
|
@ -69,12 +84,19 @@ fun SearchScreen(
|
|||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
when {
|
||||
state.loading -> Box(
|
||||
// Loading WITH cached results: thin progress bar above the
|
||||
// list, results stay visible. audit B-1 — the prior
|
||||
// order short-circuited to a centered spinner and hid the
|
||||
// cached preview the VM was trying to show.
|
||||
state.loading && state.results.isEmpty() -> Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
|
||||
state.error != null -> Box(
|
||||
// Error WITH cached results: thin error banner above the
|
||||
// list. Audit B-2 — error branch used to clobber the
|
||||
// cached preview the VM explicitly kept.
|
||||
state.error != null && state.results.isEmpty() -> Box(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
|
|
@ -116,32 +138,82 @@ fun SearchScreen(
|
|||
contentAlignment = Alignment.Center,
|
||||
) { Text("hit enter to search") }
|
||||
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.results) { item ->
|
||||
ResultRow(item = item) { onOpenVideo(item.url, item.title) }
|
||||
HorizontalDivider()
|
||||
else -> Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (state.loading) {
|
||||
androidx.compose.material3.LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
if (state.error != null) {
|
||||
Text(
|
||||
text = "refresh failed: ${state.error}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
if (state.fromCache) {
|
||||
Text(
|
||||
text = if (state.loading) "Cached results · refreshing…"
|
||||
else "Cached results · hit Search for fresh",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState()
|
||||
val filteredResults = remember(state.results, hideShorts) {
|
||||
com.sulkta.straw.util.applyContentFilters(state.results, hideShorts = hideShorts)
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = rememberBottomContentPadding(),
|
||||
) {
|
||||
items(filteredResults) { item ->
|
||||
ResultRow(
|
||||
item = item,
|
||||
onClick = { onOpenVideo(item.url, item.title) },
|
||||
onLongClick = {
|
||||
actionTarget = VideoActionTarget(
|
||||
streamUrl = item.url,
|
||||
title = item.title,
|
||||
uploader = item.uploader,
|
||||
thumbnail = item.thumbnail,
|
||||
)
|
||||
},
|
||||
onChannelClick = { url -> onOpenChannel(url, item.uploader) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ResultRow(item: StreamItem, onClick: () -> Unit) {
|
||||
private fun ResultRow(
|
||||
item: StreamItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onChannelClick: (url: String) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.thumbnail,
|
||||
contentDescription = null,
|
||||
VideoThumbnail(
|
||||
thumbnail = item.thumbnail,
|
||||
videoUrl = item.url,
|
||||
durationSeconds = item.durationSeconds,
|
||||
modifier = Modifier
|
||||
.width(160.dp)
|
||||
.height(90.dp)
|
||||
.clip(RoundedCornerShape(6.dp)),
|
||||
.height(90.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
|
|
@ -153,23 +225,49 @@ private fun ResultRow(item: StreamItem, onClick: () -> Unit) {
|
|||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// Uploader on its own line — larger + tinted + clickable
|
||||
// when we have a uploaderUrl to route to. Tapping the
|
||||
// name jumps to the Channel screen; tapping anywhere else
|
||||
// on the row still opens the video. Child clickable
|
||||
// consumes the press before the row's clickable hears it.
|
||||
val uploaderUrl = item.uploaderUrl
|
||||
Text(
|
||||
text = buildString {
|
||||
append(item.uploader)
|
||||
if (item.viewCount > 0) {
|
||||
append(" · ")
|
||||
append(formatViews(item.viewCount))
|
||||
}
|
||||
if (item.durationSeconds > 0) {
|
||||
append(" · ")
|
||||
append(formatDuration(item.durationSeconds))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
text = item.uploader,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (!uploaderUrl.isNullOrBlank())
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = if (!uploaderUrl.isNullOrBlank())
|
||||
Modifier
|
||||
.clickable { onChannelClick(uploaderUrl) }
|
||||
.padding(vertical = 4.dp)
|
||||
else
|
||||
Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
// Drop the duration here — VideoThumbnail's badge already
|
||||
// renders it on the bottom-right of the thumbnail. Add the
|
||||
// upload date instead so search results read like YT's
|
||||
// own format. caught with the
|
||||
// channel-page + related-row consistency pass.
|
||||
val meta = buildString {
|
||||
if (item.viewCount > 0) append(formatViews(item.viewCount))
|
||||
if (item.uploadDateRelative.isNotBlank()) {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append(item.uploadDateRelative)
|
||||
}
|
||||
}
|
||||
if (meta.isNotEmpty()) {
|
||||
Text(
|
||||
text = meta,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,26 +7,35 @@ package com.sulkta.straw.feature.search
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.sulkta.straw.data.FeedCache
|
||||
import com.sulkta.straw.data.History
|
||||
import com.sulkta.straw.util.bestThumbnail
|
||||
import com.sulkta.straw.data.SearchCache
|
||||
import com.sulkta.straw.data.Settings
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.ServiceList
|
||||
import org.schabi.newpipe.extractor.search.SearchInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = "",
|
||||
val results: List<StreamItem> = emptyList(),
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
/**
|
||||
* True when the visible results came from the local cache and we
|
||||
* have not yet replaced them with a network response. Lets the UI
|
||||
* show a faint "from cache" hint without blocking the list.
|
||||
*/
|
||||
val fromCache: Boolean = false,
|
||||
)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class StreamItem(
|
||||
val url: String,
|
||||
val title: String,
|
||||
|
|
@ -35,50 +44,211 @@ data class StreamItem(
|
|||
val thumbnail: String?,
|
||||
val durationSeconds: Long,
|
||||
val viewCount: Long,
|
||||
/** "2 days ago" / "3 weeks ago" / empty if not extracted. */
|
||||
val uploadDateRelative: String = "",
|
||||
)
|
||||
|
||||
class SearchViewModel : ViewModel() {
|
||||
private val _ui = MutableStateFlow(SearchUiState())
|
||||
val ui: StateFlow<SearchUiState> = _ui.asStateFlow()
|
||||
|
||||
/**
|
||||
* In-memory snapshot of the disk corpus (saved search results +
|
||||
* subs feed cache) for reactive filtering. Hydrated on
|
||||
* Dispatchers.IO once at VM construction and refreshed after a
|
||||
* successful submit.-C1 — the previous
|
||||
* implementation hit SharedPreferences + JSON-decoded ~225 KB on
|
||||
* every keystroke, blocking the main thread.
|
||||
*
|
||||
* Plain @Volatile not StateFlow because nothing observes it
|
||||
* ( — the StateFlow synchronization buys
|
||||
* nothing here).
|
||||
*/
|
||||
@Volatile
|
||||
private var pool: List<StreamItem> = emptyList()
|
||||
|
||||
init {
|
||||
rebuildPool()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read both caches off the main thread and replace the pool
|
||||
* snapshot. Called at construction and from Settings when the
|
||||
* cache toggle flips ON (so a re-enable picks up freshly-seeded
|
||||
* entries from a subsequent submit/refresh without waiting for
|
||||
* process death). audit B2/Q10.
|
||||
*/
|
||||
fun rebuildPool() {
|
||||
viewModelScope.launch {
|
||||
pool = if (Settings.get().cacheEnabled.value) {
|
||||
withContext(Dispatchers.IO) { buildPool() }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildPool(): List<StreamItem> = buildList {
|
||||
runCatching { SearchCache.get().load().forEach { addAll(it.items) } }
|
||||
runCatching { FeedCache.get().load().values.forEach { addAll(it.items) } }
|
||||
}.distinctBy { it.url }
|
||||
|
||||
// Track the active submit so a fresh tap of Search cancels the
|
||||
// previous network call rather than racing it.
|
||||
// `_ui.value = _ui.value.copy()` patterns + concurrent
|
||||
// submits were both lost-write hazards.
|
||||
//
|
||||
// Fence by Job identity, not query string.:
|
||||
// `onQueryChange` mutates _ui.value.query for reactive filtering
|
||||
// WITHOUT cancelling inFlight, so a string-equality fence treats
|
||||
// a still-valid result as stale just because the user kept typing
|
||||
// after submit. Job identity captures the "I am the active
|
||||
// submit" intent — only a fresh submit cancels me.
|
||||
private var inFlight: Job? = null
|
||||
|
||||
fun onQueryChange(q: String) {
|
||||
_ui.value = _ui.value.copy(query = q)
|
||||
// Clear any prior error state when the user resumes typing —
|
||||
// a failed submit's banner used to persist into the next
|
||||
// reactive preview, looking like the new query had failed.
|
||||
// audit Q3.
|
||||
_ui.update { it.copy(query = q, error = null) }
|
||||
if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
|
||||
val matches = reactiveFilter(q.trim())
|
||||
if (matches.isNotEmpty()) {
|
||||
_ui.update {
|
||||
it.copy(
|
||||
results = matches,
|
||||
fromCache = true,
|
||||
loading = false,
|
||||
)
|
||||
}
|
||||
} else if (_ui.value.fromCache) {
|
||||
_ui.update { it.copy(results = emptyList(), fromCache = false) }
|
||||
}
|
||||
} else if (q.isBlank()) {
|
||||
_ui.update { it.copy(results = emptyList(), fromCache = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val q = _ui.value.query.trim()
|
||||
if (q.isEmpty()) return
|
||||
runCatching { History.get().recordSearch(q) }
|
||||
_ui.value = _ui.value.copy(loading = true, error = null, results = emptyList())
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val items = withContext(Dispatchers.IO) { search(q) }
|
||||
_ui.value = _ui.value.copy(loading = false, results = items)
|
||||
} catch (t: Throwable) {
|
||||
_ui.value = _ui.value.copy(
|
||||
loading = false,
|
||||
error = t.message ?: t.javaClass.simpleName,
|
||||
|
||||
// Cache hit on submit: show immediately, kick off refresh
|
||||
// behind it. audit B3 — the previous shape called
|
||||
// `SearchCache.get().load()` on the main thread, doing the
|
||||
// exact ~150 KB JSON decode the reactive-filter fix was
|
||||
// supposed to eliminate. Now uses the StateFlow snapshot.
|
||||
val cached = if (Settings.get().cacheEnabled.value) {
|
||||
SearchCache.get().entries.value
|
||||
.firstOrNull { it.query.equals(q, ignoreCase = true) }
|
||||
?.items
|
||||
} else null
|
||||
// Cancel any prior in-flight submit BEFORE writing the cached
|
||||
// preview to the UI —: previously a fresh
|
||||
// submit that hit the cache could be clobbered seconds later
|
||||
// by the prior submit's late terminal write, because the
|
||||
// prior coroutine had already advanced past its `ensureActive`
|
||||
// gate by the time the new submit got around to cancelling.
|
||||
inFlight?.cancel()
|
||||
|
||||
if (cached != null && cached.isNotEmpty()) {
|
||||
_ui.update {
|
||||
it.copy(
|
||||
loading = true,
|
||||
error = null,
|
||||
results = cached,
|
||||
fromCache = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_ui.update {
|
||||
it.copy(
|
||||
loading = true,
|
||||
error = null,
|
||||
results = emptyList(),
|
||||
fromCache = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
inFlight = viewModelScope.launch {
|
||||
try {
|
||||
// strawcore.search() is suspend on the tokio runtime baked
|
||||
// into libstrawcore.so — no Dispatchers.IO wrap needed.
|
||||
val rustItems = uniffi.strawcore.search(q)
|
||||
val items = rustItems.map { r ->
|
||||
StreamItem(
|
||||
url = r.url,
|
||||
title = r.title.ifBlank { "(no title)" },
|
||||
uploader = r.uploader,
|
||||
uploaderUrl = r.uploaderUrl,
|
||||
thumbnail = r.thumbnail,
|
||||
durationSeconds = r.durationSeconds,
|
||||
viewCount = r.viewCount,
|
||||
uploadDateRelative = r.uploadDateRelative,
|
||||
)
|
||||
}
|
||||
// Fence by job identity (ensureActive) — only a fresh
|
||||
// submit that called inFlight?.cancel() invalidates
|
||||
// me. Bare typing in the search bar (onQueryChange)
|
||||
// doesn't cancel anything, so our results are still
|
||||
// valid even if `_ui.value.query` moved on.
|
||||
ensureActive()
|
||||
_ui.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
results = items,
|
||||
fromCache = false,
|
||||
)
|
||||
}
|
||||
// Record AFTER the search succeeds so mistyped queries
|
||||
// that error out don't pollute the recent-searches list.
|
||||
runCatching { History.get().recordSearch(q) }
|
||||
if (Settings.get().cacheEnabled.value) {
|
||||
withContext(Dispatchers.IO) {
|
||||
// Re-check active state after the dispatcher
|
||||
// switch (still cooperative cancellation).
|
||||
ensureActive()
|
||||
runCatching { SearchCache.get().record(q, items) }
|
||||
// Refresh the in-memory pool with the new
|
||||
// entries so subsequent reactive filters see
|
||||
// them without waiting for a process restart.
|
||||
pool = buildPool()
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
// Keep the cached preview visible on network failure so
|
||||
// the user still has something to look at while offline.
|
||||
_ui.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
error = com.sulkta.straw.util.LogDump.scrubLine(
|
||||
t.message ?: t.javaClass.simpleName,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun search(query: String): List<StreamItem> {
|
||||
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
|
||||
val qh = service.searchQHFactory.fromQuery(query, emptyList(), "")
|
||||
val info = SearchInfo.getInfo(service, qh)
|
||||
return info.relatedItems
|
||||
.filterIsInstance<StreamInfoItem>()
|
||||
.map {
|
||||
StreamItem(
|
||||
url = it.url,
|
||||
title = it.name ?: "(no title)",
|
||||
uploader = it.uploaderName ?: "",
|
||||
uploaderUrl = it.uploaderUrl,
|
||||
thumbnail = bestThumbnail(it.thumbnails),
|
||||
durationSeconds = it.duration,
|
||||
viewCount = it.viewCount,
|
||||
)
|
||||
/**
|
||||
* Walk the in-memory `pool` and return items whose title or uploader
|
||||
* contains the query. Case-insensitive, capped at 60 results.
|
||||
* No disk I/O on the hot path — `pool` is refreshed off-thread
|
||||
* after each successful submit and at VM construction.
|
||||
*/
|
||||
private fun reactiveFilter(q: String): List<StreamItem> {
|
||||
// contains(ignoreCase=true) on the raw fields avoids the
|
||||
// 3N+ String allocations per keystroke that `.lowercase()`
|
||||
// copy-and-compare produced.
|
||||
return pool.asSequence()
|
||||
.filter { item ->
|
||||
item.title.contains(q, ignoreCase = true)
|
||||
|| item.uploader.contains(q, ignoreCase = true)
|
||||
}
|
||||
.take(60)
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,46 +5,103 @@
|
|||
|
||||
package com.sulkta.straw.feature.settings
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import com.sulkta.straw.feature.player.NowPlaying
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.compose.material3.FilterChip
|
||||
import com.sulkta.straw.BuildConfig
|
||||
import com.sulkta.straw.data.AutoUpdateInterval
|
||||
import com.sulkta.straw.data.BgFeedRefreshInterval
|
||||
import com.sulkta.straw.data.CacheCap
|
||||
import com.sulkta.straw.data.CacheTtl
|
||||
import com.sulkta.straw.data.FeedCache
|
||||
import com.sulkta.straw.data.Resume
|
||||
import com.sulkta.straw.feature.feed.FeedRefreshScheduler
|
||||
import com.sulkta.straw.feature.update.UpdateScheduler
|
||||
import com.sulkta.straw.feature.update.runUpdateCheck
|
||||
import com.sulkta.straw.util.formatRelativeSince
|
||||
import com.sulkta.straw.data.History
|
||||
import com.sulkta.straw.data.AutoplayMode
|
||||
import com.sulkta.straw.data.MaxResolution
|
||||
import com.sulkta.straw.data.SbCategory
|
||||
import com.sulkta.straw.data.SearchCache
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.data.ThemeMode
|
||||
import com.sulkta.straw.feature.dataimport.ImportResult
|
||||
import com.sulkta.straw.feature.dataimport.SettingsImport
|
||||
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
|
||||
import com.sulkta.straw.feature.search.SearchViewModel
|
||||
import com.sulkta.straw.util.LogDump
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen() {
|
||||
val store = Settings.get()
|
||||
val cats by store.sbCategories.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var importRunning by remember { mutableStateOf(false) }
|
||||
var importResult by remember { mutableStateOf<Result<ImportResult>?>(null) }
|
||||
val pickZip = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
importRunning = true
|
||||
scope.launch {
|
||||
importResult = SettingsImport.run(context, uri)
|
||||
importRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the gesture-bar / 3-button nav bar at the bottom and add
|
||||
// extra room for the minibar overlay when something's playing —
|
||||
// otherwise the bottom rows of Settings render UNDER both. The
|
||||
// minibar is a process-wide BottomCenter overlay (StrawActivity
|
||||
// ScreenContent) so each scrolling screen has to leave its own gap.
|
||||
val showingMinibar by NowPlaying.current.collectAsState()
|
||||
val minibarReserve = if (showingMinibar != null) 72.dp else 0.dp
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
) {
|
||||
|
|
@ -109,6 +166,533 @@ fun SettingsScreen() {
|
|||
HorizontalDivider()
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
"Appearance",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
"Light, dark, or follow the system setting.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val theme by store.themeMode.collectAsState()
|
||||
ThemeMode.entries.forEach { t ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { store.setThemeMode(t) }
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = if (t == theme) "• ${t.label}" else " ${t.label}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (t == theme) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
"Autoplay",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
"When a video ends with nothing left in the queue, what should " +
|
||||
"play next? Queue auto-advance always works regardless of " +
|
||||
"this setting — autoplay only kicks in at the end.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val autoplayMode by store.autoplayMode.collectAsState()
|
||||
AutoplayMode.entries.forEach { m ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { store.setAutoplayMode(m) }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = if (m == autoplayMode) "• ${m.label}" else " ${m.label}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (m == autoplayMode) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
m.help,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 24.dp, bottom = 4.dp),
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val skipWatched by store.autoplaySkipWatched.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Skip already-watched videos",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"Autoplay picks the next un-watched video.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = skipWatched,
|
||||
onCheckedChange = { store.setAutoplaySkipWatched(it) },
|
||||
)
|
||||
}
|
||||
val autoStartPlayback by store.autoStartPlayback.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Auto-start playback",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"Open a video → it starts immediately. Off: tap " +
|
||||
"the thumbnail to start.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = autoStartPlayback,
|
||||
onCheckedChange = { store.setAutoStartPlayback(it) },
|
||||
)
|
||||
}
|
||||
val pauseOnHeadphones by store.pauseOnHeadphoneDisconnect.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Pause on headphone disconnect",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"Wired pull / Bluetooth drop → pause instead of " +
|
||||
"switching to the phone speaker.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = pauseOnHeadphones,
|
||||
onCheckedChange = { store.setPauseOnHeadphoneDisconnect(it) },
|
||||
)
|
||||
}
|
||||
val autoResume by store.autoResume.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Resume where you left off",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"Reopen a video → pick up at the saved scrub-point. " +
|
||||
"Off: every open starts at 0:00.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = autoResume,
|
||||
onCheckedChange = { store.setAutoResume(it) },
|
||||
)
|
||||
}
|
||||
val hideShorts by store.hideShorts.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Hide Shorts",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"Drop /shorts/ URLs from search + channel pages " +
|
||||
"and best-effort filter (\"#shorts\" tag) on the " +
|
||||
"subs feed.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = hideShorts,
|
||||
onCheckedChange = { store.setHideShorts(it) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
"App updates",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
"Polls fdroid.sulkta.com for newer Straw builds. When one's " +
|
||||
"available, a notification taps through to the system " +
|
||||
"installer. NewPipe's silent-staleness problem, solved.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val autoUpdateCheck by store.autoUpdateCheck.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Check for updates",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"Background poll. Tap the notification to install.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = autoUpdateCheck,
|
||||
onCheckedChange = { checked ->
|
||||
store.setAutoUpdateCheck(checked)
|
||||
UpdateScheduler.applyFromSettings(context)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (autoUpdateCheck) {
|
||||
val interval by store.autoUpdateInterval.collectAsState()
|
||||
Text(
|
||||
"Interval",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
AutoUpdateInterval.entries.forEach { opt ->
|
||||
FilterChip(
|
||||
selected = interval == opt,
|
||||
onClick = {
|
||||
store.setAutoUpdateInterval(opt)
|
||||
UpdateScheduler.applyFromSettings(context)
|
||||
},
|
||||
label = { Text(opt.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val lastCheckMs by store.lastUpdateCheckMs.collectAsState()
|
||||
val latestVc by store.latestKnownVc.collectAsState()
|
||||
val latestVname by store.latestKnownVname.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
val lastText = if (lastCheckMs <= 0L) {
|
||||
"Never checked."
|
||||
} else {
|
||||
"Last checked: ${formatRelativeSince(lastCheckMs)}."
|
||||
}
|
||||
Text(
|
||||
lastText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (latestVc > 0L && latestVc > BuildConfig.VERSION_CODE) {
|
||||
val label = latestVname.ifBlank { "vc=$latestVc" }
|
||||
Text(
|
||||
"Update available: $label.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { runUpdateCheck(context) }
|
||||
}
|
||||
},
|
||||
) { Text("Check now") }
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
"Local cache",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
"Caches the subs feed and recent searches on disk so the app " +
|
||||
"paints instantly on cold start and you can search " +
|
||||
"previously-seen videos with no network. ~400 KB max. " +
|
||||
"Turn it off to save space on low-storage devices.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val cacheEnabled by store.cacheEnabled.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
"Enable local cache",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
val feedVm: SubscriptionFeedViewModel = viewModel()
|
||||
val searchVm: SearchViewModel = viewModel()
|
||||
Switch(
|
||||
checked = cacheEnabled,
|
||||
onCheckedChange = { checked ->
|
||||
store.setCacheEnabled(checked)
|
||||
scope.launch {
|
||||
if (!checked) {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { FeedCache.get().clear() }
|
||||
runCatching { SearchCache.get().clear() }
|
||||
}
|
||||
feedVm.clearInMemoryCache()
|
||||
// Drop the in-memory reactive-search pool
|
||||
// too — without this, typing into Search
|
||||
// still surfaces hits from the just-wiped
|
||||
// disk cache.
|
||||
searchVm.rebuildPool()
|
||||
} else {
|
||||
// Cache re-enabled: trigger a real refresh
|
||||
// so the feed repopulates without waiting
|
||||
// for the user to navigate away and back.
|
||||
// audit B2.
|
||||
feedVm.refresh()
|
||||
searchVm.rebuildPool()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Background refresh",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
"Periodically pre-fetch the subs feed so the next time you " +
|
||||
"open Straw the latest videos are already there. Off by " +
|
||||
"default (battery cost on cell).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val bgEnabled by store.bgFeedRefreshEnabled.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
"Auto-refresh subs",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Switch(
|
||||
checked = bgEnabled,
|
||||
onCheckedChange = { checked ->
|
||||
store.setBgFeedRefreshEnabled(checked)
|
||||
FeedRefreshScheduler.applyFromSettings(context)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (bgEnabled) {
|
||||
val bgInterval by store.bgFeedRefreshInterval.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
BgFeedRefreshInterval.entries.forEach { opt ->
|
||||
FilterChip(
|
||||
selected = bgInterval == opt,
|
||||
onClick = {
|
||||
store.setBgFeedRefreshInterval(opt)
|
||||
FeedRefreshScheduler.applyFromSettings(context)
|
||||
},
|
||||
label = { Text(opt.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Cache & history limits",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
"Pick how much to keep. Unlimited = no auto-pruning. Old " +
|
||||
"entries beyond a TTL are dropped on read.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
// Sample on-disk usage once per Settings entry — File.length() is
|
||||
// cheap but we don't need it to recompose on every state change.
|
||||
// remember keeps the same snapshot for the entire session.
|
||||
val usage = remember {
|
||||
object {
|
||||
val history = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_history")
|
||||
val resume = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_resume_positions")
|
||||
val search = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_search_cache")
|
||||
val feed = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_feed_cache")
|
||||
val coil = com.sulkta.straw.util.StorageUsage.coilDiskCacheBytes(context)
|
||||
}
|
||||
}
|
||||
CacheCapRow(
|
||||
label = "Watch + search history",
|
||||
selected = store.historyWatchesCap.collectAsState().value,
|
||||
onPick = { store.setHistoryWatchesCap(it) },
|
||||
usageBytes = usage.history,
|
||||
)
|
||||
CacheCapRow(
|
||||
label = "Search history",
|
||||
selected = store.historySearchesCap.collectAsState().value,
|
||||
onPick = { store.setHistorySearchesCap(it) },
|
||||
)
|
||||
CacheCapRow(
|
||||
label = "Resume positions",
|
||||
selected = store.resumePositionsCap.collectAsState().value,
|
||||
onPick = { store.setResumePositionsCap(it) },
|
||||
usageBytes = usage.resume,
|
||||
)
|
||||
CacheCapRow(
|
||||
label = "Search results cache",
|
||||
selected = store.searchCacheCap.collectAsState().value,
|
||||
onPick = { store.setSearchCacheCap(it) },
|
||||
usageBytes = usage.search,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Subs feed cache",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = "Used: ${com.sulkta.straw.util.StorageUsage.format(usage.feed)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Image cache (thumbnails)",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = "Used: ${com.sulkta.straw.util.StorageUsage.format(usage.coil)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"Cache TTL",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"Drop subs feed + search cache entries older than this.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val ttl by store.cacheTtl.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CacheTtl.entries.forEach { opt ->
|
||||
FilterChip(
|
||||
selected = ttl == opt,
|
||||
onClick = { store.setCacheTtl(opt) },
|
||||
label = { Text(opt.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
"History",
|
||||
|
|
@ -124,6 +708,113 @@ fun SettingsScreen() {
|
|||
Text("Clear searches")
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { FeedCache.get().clear() }
|
||||
runCatching { SearchCache.get().clear() }
|
||||
runCatching { Resume.get().clearAll() }
|
||||
runCatching { History.get().clearWatches() }
|
||||
runCatching { History.get().clearSearches() }
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text("Clear all caches") }
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
"Diagnostics",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
"Dump this app's recent logcat to a text file and open the " +
|
||||
"system share sheet — attach it when reporting an issue.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
var logDumping by remember { mutableStateOf(false) }
|
||||
OutlinedButton(
|
||||
enabled = !logDumping,
|
||||
onClick = {
|
||||
logDumping = true
|
||||
scope.launch {
|
||||
val outcome = LogDump.capture(context)
|
||||
logDumping = false
|
||||
outcome.onSuccess { intent ->
|
||||
context.startActivity(
|
||||
android.content.Intent.createChooser(intent, "Share Straw logs"),
|
||||
)
|
||||
}
|
||||
outcome.onFailure { t ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Log dump failed: ${t.message ?: t.javaClass.simpleName}",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(if (logDumping) "Exporting…" else "Export logs…")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
"Import from NewPipe / Tubular",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
"Pick a NewPipeData-*.zip or TubularData-*.zip — we'll lift " +
|
||||
"your subscriptions, playlists, search history, watch history " +
|
||||
"(capped to 50 most recent), and a curated subset of settings. " +
|
||||
"Other services (SoundCloud, PeerTube) are skipped.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
enabled = !importRunning,
|
||||
onClick = { pickZip.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) },
|
||||
) {
|
||||
if (importRunning) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.height(18.dp).padding(end = 8.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text("Importing…")
|
||||
} else {
|
||||
Text("Pick export file…")
|
||||
}
|
||||
}
|
||||
|
||||
// Tail spacer to clear the minibar overlay when something's
|
||||
// playing. Without this the last Settings row gets eaten by
|
||||
// the 64dp BottomCenter chip.
|
||||
Spacer(modifier = Modifier.height(minibarReserve))
|
||||
}
|
||||
|
||||
importResult?.let { res ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { importResult = null },
|
||||
title = { Text(if (res.isSuccess) "Import complete" else "Import failed") },
|
||||
text = {
|
||||
val body = res.fold(
|
||||
onSuccess = { it.summary() },
|
||||
onFailure = { it.message ?: it.javaClass.simpleName },
|
||||
)
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { importResult = null }) { Text("OK") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,3 +843,46 @@ private fun CategoryRow(
|
|||
Switch(checked = enabled, onCheckedChange = { onToggle() })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact chip-group row for picking a CacheCap. Label on the left,
|
||||
* 5 chips on the right, optional "Used: X KB" suffix to the right
|
||||
* of the label so the user can see what each cap is doing.
|
||||
*/
|
||||
@Composable
|
||||
private fun CacheCapRow(
|
||||
label: String,
|
||||
selected: CacheCap,
|
||||
onPick: (CacheCap) -> Unit,
|
||||
usageBytes: Long = 0L,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (usageBytes > 0L) {
|
||||
Text(
|
||||
text = "Used: ${com.sulkta.straw.util.StorageUsage.format(usageBytes)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
CacheCap.entries.forEach { opt ->
|
||||
FilterChip(
|
||||
selected = selected == opt,
|
||||
onClick = { onPick(opt) },
|
||||
label = { Text(opt.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Poll Sulkta's F-Droid repo for a newer Straw build. Returns the
|
||||
* highest versionCode + the APK download URL so the worker can post
|
||||
* a notification.
|
||||
*
|
||||
* F-Droid's index-v2.json is the canonical machine-readable shape; we
|
||||
* parse just the subset we care about (versions.* → manifest.versionCode
|
||||
* + file.name). `ignoreUnknownKeys` keeps us forward-compat with new
|
||||
* fields fdroidserver may add later.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.update
|
||||
|
||||
import com.sulkta.straw.BuildConfig
|
||||
import com.sulkta.straw.util.runCatchingCancellable
|
||||
import com.sulkta.straw.util.strawLogW
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.CertificatePinner
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val INDEX_HOST = "fdroid.sulkta.com"
|
||||
private const val INDEX_URL = "https://fdroid.sulkta.com/fdroid/repo/index-v2.json"
|
||||
private const val REPO_BASE = "https://fdroid.sulkta.com/fdroid/repo"
|
||||
|
||||
/**
|
||||
* Only accept file names that look like a plain APK basename. The index
|
||||
* controls a string we substitute into an `ACTION_VIEW` intent; without
|
||||
* sanitization a hostile or compromised index could ship `..//host/x.apk`
|
||||
* or worse.
|
||||
*/
|
||||
private val APK_NAME_RE = Regex("""^/[A-Za-z0-9._-]+\.apk$""")
|
||||
|
||||
/**
|
||||
* Sanity-cap on parsed versionCode. Straw vc is currently low double
|
||||
* digits; ten million is a horizon we won't hit organically but blocks
|
||||
* a hostile index from latching us to Long.MAX_VALUE and burying every
|
||||
* legitimate update behind a "you're already up to date" check.
|
||||
*/
|
||||
private const val MAX_PLAUSIBLE_VC = 10_000_000L
|
||||
|
||||
data class UpdateInfo(
|
||||
val versionCode: Long,
|
||||
val versionName: String,
|
||||
val apkUrl: String,
|
||||
)
|
||||
|
||||
object AppUpdateClient {
|
||||
/**
|
||||
* Pin two Subject-Public-Key-Info SHA-256 hashes against
|
||||
* fdroid.sulkta.com so an off-tree CA misissue can't ship the
|
||||
* user an attacker-signed index.
|
||||
*
|
||||
* - sha256/8ofd... — current leaf SPKI. Rotates every ~90 days
|
||||
* with each Let's Encrypt renewal; an app update before the
|
||||
* next rotation refreshes this pin.
|
||||
* - sha256/y7xV... — Let's Encrypt E7 intermediate SPKI. Stable
|
||||
* for years; serves as the rotation-safety pin while we push
|
||||
* a new leaf hash.
|
||||
*
|
||||
* When the leaf pin no longer matches (post-rotation), OkHttp
|
||||
* still accepts the chain because the E7 intermediate pin
|
||||
* matches. The next app release rolls the leaf forward.
|
||||
*/
|
||||
private val pinner: CertificatePinner = CertificatePinner.Builder()
|
||||
.add(INDEX_HOST, "sha256/8ofdiPS6TAiUx9zb2O7Qa9IKZQ3D2i+18teKCrz/MqA=")
|
||||
.add(INDEX_HOST, "sha256/y7xVm0TVJNahMr2sZydE2jQH8SquXV9yLF9seROHHHU=")
|
||||
.build()
|
||||
|
||||
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.certificatePinner(pinner)
|
||||
.build()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Fetch + parse the repo index, return the highest-versionCode entry
|
||||
* for THIS app's package. Returns null on network/parse failure (the
|
||||
* caller treats null as "no update available, try again later").
|
||||
*/
|
||||
suspend fun fetchLatest(): UpdateInfo? = withContext(Dispatchers.IO) {
|
||||
runCatchingCancellable {
|
||||
val req = Request.Builder().url(INDEX_URL).build()
|
||||
val raw = http.newCall(req).execute().use { resp ->
|
||||
if (!resp.isSuccessful) return@runCatchingCancellable null
|
||||
resp.body.string()
|
||||
}
|
||||
val index = json.decodeFromString<FdroidIndex>(raw)
|
||||
val pkg = index.packages[BuildConfig.APPLICATION_ID]
|
||||
?: return@runCatchingCancellable null
|
||||
val best = pkg.versions.values
|
||||
.maxByOrNull { it.manifest.versionCode }
|
||||
?: return@runCatchingCancellable null
|
||||
// Reject implausible versionCodes outright — see
|
||||
// MAX_PLAUSIBLE_VC.
|
||||
if (best.manifest.versionCode <= 0 ||
|
||||
best.manifest.versionCode > MAX_PLAUSIBLE_VC) {
|
||||
strawLogW("StrawUpdate") {
|
||||
"rejecting implausible versionCode=${best.manifest.versionCode}"
|
||||
}
|
||||
return@runCatchingCancellable null
|
||||
}
|
||||
// Strict APK-basename match before we hand this off to
|
||||
// ACTION_VIEW. Anything else gets logged + dropped.
|
||||
val fileName = best.file.name
|
||||
if (!APK_NAME_RE.matches(fileName)) {
|
||||
strawLogW("StrawUpdate") {
|
||||
"rejecting unsafe file.name=${fileName.take(80)}"
|
||||
}
|
||||
return@runCatchingCancellable null
|
||||
}
|
||||
UpdateInfo(
|
||||
versionCode = best.manifest.versionCode,
|
||||
versionName = best.manifest.versionName.orEmpty(),
|
||||
apkUrl = "$REPO_BASE$fileName",
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class FdroidIndex(val packages: Map<String, FdroidPackage> = emptyMap())
|
||||
|
||||
@Serializable
|
||||
private data class FdroidPackage(val versions: Map<String, FdroidVersion> = emptyMap())
|
||||
|
||||
@Serializable
|
||||
private data class FdroidVersion(val file: FdroidFile, val manifest: FdroidManifest)
|
||||
|
||||
@Serializable
|
||||
private data class FdroidFile(val name: String)
|
||||
|
||||
@Serializable
|
||||
private data class FdroidManifest(
|
||||
val versionCode: Long,
|
||||
val versionName: String? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Periodic + on-demand self-update check. WorkManager fires this on the
|
||||
* cadence the user picked in Settings; on cold start StrawApp also
|
||||
* kicks one off so the user sees pending updates without waiting a full
|
||||
* interval. The runner is small + bounded — fetch index, compare, post
|
||||
* notification, done.
|
||||
*
|
||||
* NewPipe's biggest UX gap is silent staleness: users sit on
|
||||
* months-old builds because nothing tells them to update. This worker
|
||||
* + the SettingsScreen toggle close that gap without trying to be a
|
||||
* full updater (Android won't let a non-system app silent-install
|
||||
* APKs anyway). Tap the notification → ACTION_VIEW the APK URL → the
|
||||
* system handles download + install confirm.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.update
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.sulkta.straw.BuildConfig
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.util.strawLogI
|
||||
|
||||
/**
|
||||
* Single source of truth for "did we find a newer version?" logic.
|
||||
* Touched by both the scheduled worker AND the "Check now" Settings
|
||||
* button so behavior stays identical regardless of trigger.
|
||||
*/
|
||||
suspend fun runUpdateCheck(context: Context): UpdateInfo? {
|
||||
val info = AppUpdateClient.fetchLatest()
|
||||
Settings.get().setLastUpdateCheck(System.currentTimeMillis())
|
||||
if (info == null) {
|
||||
strawLogI("update", "check: network/parse failure, will retry")
|
||||
return null
|
||||
}
|
||||
if (info.versionCode <= BuildConfig.VERSION_CODE) {
|
||||
strawLogI("update", "check: up to date (latest=${info.versionCode})")
|
||||
Settings.get().setLatestKnownVersion(0L, "")
|
||||
return null
|
||||
}
|
||||
strawLogI("update", "check: ${BuildConfig.VERSION_CODE} → ${info.versionCode} available")
|
||||
Settings.get().setLatestKnownVersion(info.versionCode, info.versionName)
|
||||
postUpdateNotification(context, info)
|
||||
return info
|
||||
}
|
||||
|
||||
class UpdateCheckWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
if (!Settings.get().autoUpdateCheck.value) return Result.success()
|
||||
runUpdateCheck(applicationContext)
|
||||
// Always succeed — a failed check just retries on the next
|
||||
// scheduled tick. Retry-with-backoff would burn battery for no
|
||||
// gain (the index is sticky and fdroid.sulkta.com is on Sulkta
|
||||
// infra, not a third-party rate limiter).
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
private const val NOTIF_CHANNEL_ID = "straw-update"
|
||||
private const val NOTIF_ID = 23
|
||||
|
||||
private fun postUpdateNotification(context: Context, info: UpdateInfo) {
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = NotificationChannel(
|
||||
NOTIF_CHANNEL_ID,
|
||||
"Straw updates",
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Notifies when a newer Straw build is on fdroid.sulkta.com."
|
||||
}
|
||||
nm.createNotificationChannel(channel)
|
||||
|
||||
// ACTION_VIEW on the APK URL — Chrome / system browser fetches it
|
||||
// via DownloadManager and the user taps it to install. No
|
||||
// INSTALL_PACKAGES permission needed; the system installer handles
|
||||
// the confirm dialog.
|
||||
val viewIntent = Intent(Intent.ACTION_VIEW, Uri.parse(info.apkUrl))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val pending = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
viewIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val name = info.versionName.ifBlank { "vc=${info.versionCode}" }
|
||||
val notif = NotificationCompat.Builder(context, NOTIF_CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle("Straw $name available")
|
||||
.setContentText("Tap to download from fdroid.sulkta.com.")
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
nm.notify(NOTIF_ID, notif)
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Wires the user's auto-update preferences into WorkManager. Called
|
||||
* from StrawApp.onCreate (initial enqueue) and from SettingsScreen
|
||||
* (re-apply when the toggle / interval flips).
|
||||
*
|
||||
* Uses unique-name + REPLACE so flipping the interval mid-flight just
|
||||
* swaps the schedule instead of stacking workers.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.update
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.sulkta.straw.data.AutoUpdateInterval
|
||||
import com.sulkta.straw.data.Settings
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val WORK_NAME = "straw-update-check"
|
||||
|
||||
object UpdateScheduler {
|
||||
fun applyFromSettings(context: Context) {
|
||||
val s = Settings.get()
|
||||
val enabled = s.autoUpdateCheck.value
|
||||
val interval = s.autoUpdateInterval.value
|
||||
val wm = WorkManager.getInstance(context.applicationContext)
|
||||
if (!enabled) {
|
||||
wm.cancelUniqueWork(WORK_NAME)
|
||||
return
|
||||
}
|
||||
// WorkManager floors periodic intervals at 15 minutes.
|
||||
// coerceAtLeast(15) future-proofs against a smaller enum case
|
||||
// landing without anyone noticing the silent clamp.
|
||||
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(
|
||||
interval.minutes.coerceAtLeast(15L),
|
||||
TimeUnit.MINUTES,
|
||||
).setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build(),
|
||||
).build()
|
||||
wm.enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the user-facing AutoUpdateInterval enum to minutes for
|
||||
* WorkManager. WM enforces a 15-minute floor on periodic work; any
|
||||
* value below that would silently be clamped.
|
||||
*/
|
||||
private val AutoUpdateInterval.minutes: Long
|
||||
get() = when (this) {
|
||||
AutoUpdateInterval.H1 -> 60
|
||||
AutoUpdateInterval.H6 -> 6 * 60
|
||||
AutoUpdateInterval.H24 -> 24 * 60
|
||||
}
|
||||
|
|
@ -13,9 +13,36 @@
|
|||
|
||||
package com.sulkta.straw.net
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.ResponseBody
|
||||
import okio.Buffer
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Path C-6 / Phase U-5: USER_AGENT + shared OkHttpClient that previously
|
||||
* lived on NewPipeDownloader. After ripping NewPipeExtractor, the RYD +
|
||||
* SponsorBlock + ExoPlayer HTTP factories still need both. One shared
|
||||
* client is fine.
|
||||
*/
|
||||
const val STRAW_USER_AGENT: String =
|
||||
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Straw/1.0"
|
||||
|
||||
// OkHttpClient is internally thread-safe; lazy(SYNCHRONIZED) builds
|
||||
// exactly once across threads. — the prior
|
||||
// synchronized(STRAW_USER_AGENT) locked an interned String literal
|
||||
// shared with any other code in any library that happened to lock
|
||||
// the same literal. Lazy-delegate avoids the global pool lock.
|
||||
private val sharedClient: OkHttpClient by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun strawHttpClient(): OkHttpClient = sharedClient
|
||||
|
||||
fun ResponseBody.cappedString(maxBytes: Long): String {
|
||||
val cl = contentLength()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Path C-7 (post-audit): wrap DefaultHttpDataSource so each open() that
|
||||
* lacks a bounded length turns into a sequence of bounded Range requests
|
||||
* (default 1 MiB chunks).
|
||||
*
|
||||
* Background: rustypipe's iOS InnerTube client returns pre-signed
|
||||
* googlevideo URLs. Those URLs reject an open-ended `Range: bytes=N-`
|
||||
* with HTTP 403 — they only accept bounded `Range: bytes=N-M`. ExoPlayer
|
||||
* issues open-ended Range requests by default, which made every non-HLS
|
||||
* iOS-origin video 403 on first byte. This shim makes ExoPlayer
|
||||
* iOS-shaped without touching media-source selection.
|
||||
*
|
||||
* Verified via 2026-05-24 emulator audit (memory/audit-straw-vc16-emulator-2026-05-24.md):
|
||||
* curl -r 0-1023 → 206 OK
|
||||
* curl -H "Range: bytes=0-" → 403 (any UA)
|
||||
* curl no Range → 403
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.net
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.HttpDataSource
|
||||
import com.sulkta.straw.util.strawLogD
|
||||
import com.sulkta.straw.util.strawLogW
|
||||
|
||||
private const val TAG = "IosSafeDS"
|
||||
|
||||
@UnstableApi
|
||||
class IosSafeHttpDataSource(
|
||||
private val inner: HttpDataSource,
|
||||
private val chunkBytes: Long = DEFAULT_CHUNK_BYTES,
|
||||
) : HttpDataSource by inner {
|
||||
|
||||
/** The original (caller-supplied) spec — kept so we can compute the next chunk. */
|
||||
private var originalSpec: DataSpec? = null
|
||||
|
||||
/** How many bytes have been read since the caller's open(). */
|
||||
private var totalRead: Long = 0
|
||||
|
||||
/** Bytes left in the current inner-open chunk. -1 = unknown end. */
|
||||
private var chunkRemaining: Long = 0
|
||||
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
// When length is set, respect it but still cap the first inner-open to
|
||||
// chunkBytes. When length is unset, request a chunk and we'll roll
|
||||
// forward on subsequent reads.
|
||||
val requestLen = if (dataSpec.length == C.LENGTH_UNSET.toLong()) {
|
||||
chunkBytes
|
||||
} else {
|
||||
minOf(dataSpec.length, chunkBytes)
|
||||
}
|
||||
// NOTE: DataSpec.subrange(offset, length) ADDS offset to the existing
|
||||
// position — so subrange(position, length) doubles the position. Use
|
||||
// buildUpon().setLength(...) which preserves position and only bounds
|
||||
// the byte length. This is what makes ExoPlayer's first Range header
|
||||
// come out as `bytes=N-M` (closed, accepted by googlevideo iOS URLs)
|
||||
// instead of `bytes=N-` (open, rejected with 403).
|
||||
val bounded = dataSpec.buildUpon().setLength(requestLen).build()
|
||||
// Surface itag + mime from query so we can tell video vs audio apart in
|
||||
// logs. SECURITY: do NOT include the full URL — pre-signed googlevideo
|
||||
// URLs contain session-bound credentials (signature, sig, pot, expire,
|
||||
// cpn) that would otherwise ride a `LogDump.capture` straight into the
|
||||
// user's share-sheet target. Host + itag is enough to debug from.
|
||||
val u = dataSpec.uri
|
||||
val itag = u.getQueryParameter("itag")
|
||||
val mime = u.getQueryParameter("mime")
|
||||
strawLogD(TAG) {
|
||||
"open: host=${u.host} itag=$itag mime=$mime " +
|
||||
"pos=${bounded.position} len=${bounded.length} " +
|
||||
"(origLen=${dataSpec.length}, chunkBytes=$chunkBytes)"
|
||||
}
|
||||
originalSpec = dataSpec
|
||||
totalRead = 0
|
||||
// inner.open() returns the BOUNDED chunk's length. Track it so we
|
||||
// know when to roll to the next chunk.
|
||||
chunkRemaining = try {
|
||||
inner.open(bounded)
|
||||
} catch (t: Throwable) {
|
||||
strawLogW(TAG, t) { "open failed: ${t.javaClass.simpleName}" }
|
||||
throw t
|
||||
}
|
||||
strawLogD(TAG) { "open: inner chunkRemaining=$chunkRemaining" }
|
||||
// Report the original (potentially unbounded) length to the caller —
|
||||
// ExoPlayer cares about the overall length, not our internal chunking.
|
||||
return if (dataSpec.length == C.LENGTH_UNSET.toLong()) {
|
||||
C.LENGTH_UNSET.toLong()
|
||||
} else {
|
||||
dataSpec.length
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
if (length == 0) return 0
|
||||
// Need a fresh chunk?
|
||||
if (chunkRemaining == 0L) {
|
||||
val spec = originalSpec ?: return C.RESULT_END_OF_INPUT
|
||||
inner.close()
|
||||
val nextPos = spec.position + totalRead
|
||||
val remainingOverall = if (spec.length == C.LENGTH_UNSET.toLong()) {
|
||||
Long.MAX_VALUE
|
||||
} else {
|
||||
spec.length - totalRead
|
||||
}
|
||||
if (remainingOverall <= 0L) return C.RESULT_END_OF_INPUT
|
||||
val nextLen = remainingOverall.coerceAtMost(chunkBytes)
|
||||
// Same as in open() — use buildUpon().setPosition/setLength rather
|
||||
// than subrange() so the absolute position stays meaningful.
|
||||
val nextSpec = spec.buildUpon()
|
||||
.setPosition(nextPos)
|
||||
.setLength(nextLen)
|
||||
.build()
|
||||
chunkRemaining = inner.open(nextSpec)
|
||||
}
|
||||
// Cap the read against what's left in this chunk.
|
||||
val toRead = if (chunkRemaining < 0L) {
|
||||
// Inner doesn't know its end either; just read what was asked.
|
||||
length
|
||||
} else {
|
||||
length.toLong().coerceAtMost(chunkRemaining).toInt()
|
||||
}
|
||||
val read = inner.read(buffer, offset, toRead)
|
||||
if (read != C.RESULT_END_OF_INPUT) {
|
||||
totalRead += read.toLong()
|
||||
if (chunkRemaining > 0L) chunkRemaining -= read.toLong()
|
||||
// If chunkRemaining hits 0 here, the next read() call will roll
|
||||
// to the next chunk via the block at the top.
|
||||
} else if (chunkRemaining > 0L) {
|
||||
// Inner ran out before its advertised end. Force chunk roll on
|
||||
// next read() so we re-open at the next position.
|
||||
chunkRemaining = 0L
|
||||
}
|
||||
return read
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
try {
|
||||
inner.close()
|
||||
} finally {
|
||||
originalSpec = null
|
||||
totalRead = 0
|
||||
chunkRemaining = 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Factory: wrap any inner HttpDataSource.Factory. */
|
||||
@UnstableApi
|
||||
class Factory(
|
||||
private val innerFactory: HttpDataSource.Factory,
|
||||
private val chunkBytes: Long = DEFAULT_CHUNK_BYTES,
|
||||
) : HttpDataSource.Factory {
|
||||
override fun createDataSource(): HttpDataSource =
|
||||
IosSafeHttpDataSource(innerFactory.createDataSource(), chunkBytes)
|
||||
|
||||
override fun setDefaultRequestProperties(
|
||||
defaultRequestProperties: Map<String, String>,
|
||||
): HttpDataSource.Factory {
|
||||
innerFactory.setDefaultRequestProperties(defaultRequestProperties)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// YT's iOS-bound googlevideo URLs accept bounded `Range: bytes=N-M`
|
||||
// requests up to roughly 900 KiB before flipping to 403. Empirically
|
||||
// measured 2026-05-24 on Lucy egress: bytes=0-917503 (~896 KiB) → 206;
|
||||
// bytes=0-999999 (~977 KiB) → 403. 512 KiB gives a 2× safety margin —
|
||||
// small enough to survive future tightening, large enough to keep the
|
||||
// open() round-trip count tolerable for a long video.
|
||||
const val DEFAULT_CHUNK_BYTES: Long = 512L * 1024
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
package com.sulkta.straw.net
|
||||
|
||||
import com.sulkta.straw.extractor.NewPipeDownloader
|
||||
import com.sulkta.straw.util.strawLogD
|
||||
import com.sulkta.straw.util.strawLogW
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -26,7 +25,7 @@ data class RydVotes(
|
|||
|
||||
object RydClient {
|
||||
private const val TAG = "StrawRyd"
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/** Blocking — call from Dispatchers.IO. */
|
||||
fun fetch(videoId: String): RydVotes? {
|
||||
|
|
@ -34,11 +33,11 @@ object RydClient {
|
|||
strawLogD(TAG) { "fetch start: $videoId → $url" }
|
||||
val req = Request.Builder()
|
||||
.url(url)
|
||||
.header("User-Agent", NewPipeDownloader.USER_AGENT)
|
||||
.header("User-Agent", STRAW_USER_AGENT)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
return runCatching {
|
||||
NewPipeDownloader.client().newCall(req).execute().use { r ->
|
||||
strawHttpClient().newCall(req).execute().use { r ->
|
||||
val code = r.code
|
||||
// AUD-HIGH: bounded body read to defend against OOM.
|
||||
val bodyStr = r.body?.cappedString(RYD_MAX_BYTES) ?: ""
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@
|
|||
|
||||
package com.sulkta.straw.net
|
||||
|
||||
import com.sulkta.straw.extractor.NewPipeDownloader
|
||||
import com.sulkta.straw.util.strawLogD
|
||||
import com.sulkta.straw.util.strawLogW
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import java.security.MessageDigest
|
||||
|
||||
|
|
@ -35,23 +35,30 @@ data class SbSegment(
|
|||
|
||||
object SponsorBlockClient {
|
||||
private const val TAG = "StrawSb"
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun fetch(
|
||||
videoId: String,
|
||||
categories: List<String> = listOf("sponsor"),
|
||||
): List<SbSegment> {
|
||||
val prefix = sha256Hex(videoId).substring(0, 4)
|
||||
val urlStr = "https://sponsor.ajay.app/api/skipSegments/$prefix?" +
|
||||
"categories=" + buildJsonArray(categories)
|
||||
strawLogD(TAG) { "fetch: videoId=$videoId prefix=$prefix url=$urlStr" }
|
||||
// HttpUrl.Builder percent-encodes query values for us. Prior
|
||||
// string-concat built `?categories=["sponsor","selfpromo"]`
|
||||
// with literal brackets/quotes — SB happens to accept it
|
||||
// today, but the next time someone interpolates a non-enum
|
||||
// string in there it becomes a URL-construction bug.
|
||||
val url = "https://sponsor.ajay.app/api/skipSegments/$prefix".toHttpUrl()
|
||||
.newBuilder()
|
||||
.addQueryParameter("categories", buildJsonArray(categories))
|
||||
.build()
|
||||
strawLogD(TAG) { "fetch: videoId=$videoId prefix=$prefix" }
|
||||
val req = Request.Builder()
|
||||
.url(urlStr)
|
||||
.header("User-Agent", NewPipeDownloader.USER_AGENT)
|
||||
.url(url)
|
||||
.header("User-Agent", STRAW_USER_AGENT)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
return runCatching {
|
||||
NewPipeDownloader.client().newCall(req).execute().use { r ->
|
||||
strawHttpClient().newCall(req).execute().use { r ->
|
||||
val code = r.code
|
||||
// AUD-HIGH: bounded body read.
|
||||
val bodyStr = r.body?.cappedString(SB_MAX_BYTES) ?: ""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Shared bottom-padding helper for every scrolling screen.
|
||||
*
|
||||
* Two things float over the bottom of the activity-level layout and
|
||||
* need to be cleared:
|
||||
* 1. The system navigation bar (3-button or gesture). Insets via
|
||||
* WindowInsets.navigationBars.
|
||||
* 2. The Straw minibar overlay. Reactive — only present when
|
||||
* NowPlaying.current is non-null. ~64dp tall + a small gap →
|
||||
* 72dp reserve.
|
||||
*
|
||||
* LazyColumn-based screens plumb this into `contentPadding` so items
|
||||
* scroll PAST the bottom without being eaten. verticalScroll columns
|
||||
* append a tail Spacer of the same height.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.util
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sulkta.straw.feature.player.NowPlaying
|
||||
|
||||
/** Combined bottom Dp: nav-bar inset + 72dp when minibar's visible. */
|
||||
@Composable
|
||||
fun rememberBottomBarReserveDp(): Dp {
|
||||
val navBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
val item by NowPlaying.current.collectAsStateWithLifecycle()
|
||||
val minibar = if (item != null) 72.dp else 0.dp
|
||||
return navBottom + minibar
|
||||
}
|
||||
|
||||
/** Convenience for LazyColumn.contentPadding — adds nothing on the top/start/end. */
|
||||
@Composable
|
||||
fun rememberBottomContentPadding(): PaddingValues =
|
||||
PaddingValues(bottom = rememberBottomBarReserveDp())
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Heuristics for the hide-shorts / hide-paid / hide-age content filters.
|
||||
* Pure functions on StreamItem so any list-rendering site can call them
|
||||
* with one line at row-emit time.
|
||||
*
|
||||
* ships only the shorts heuristic — paid/age require strawcore
|
||||
* flag plumbing landing previously. The empty-stub fns are here so the
|
||||
* call sites we add now don't need to change when the flags arrive.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.util
|
||||
|
||||
import com.sulkta.straw.feature.search.StreamItem
|
||||
|
||||
/**
|
||||
* Best-effort short-video detector:
|
||||
* - URL pattern `/shorts/<id>` — reliable signal from search +
|
||||
* channel pages (strawcore preserves the original URL shape).
|
||||
* - Title contains `#shorts` / `#short` / "(shorts)" — fallback for
|
||||
* items where the URL is the canonical `watch?v=` form (RSS feed
|
||||
* items always come through this way).
|
||||
*/
|
||||
fun looksLikeShort(item: StreamItem): Boolean {
|
||||
if ("/shorts/" in item.url) return true
|
||||
val t = item.title.lowercase()
|
||||
return "#shorts" in t || "#short" in t || "(shorts)" in t
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder until adds an isPaid flag via strawcore-core.
|
||||
* Currently always false — the hide-paid toggle still shows up in
|
||||
* Settings so the user can pre-opt-in for when it lights up.
|
||||
*/
|
||||
fun looksLikePaid(@Suppress("UNUSED_PARAMETER") item: StreamItem): Boolean = false
|
||||
|
||||
/**
|
||||
* Placeholder until adds an isAgeRestricted flag. Same shape
|
||||
* as looksLikePaid.
|
||||
*/
|
||||
fun looksLikeAgeRestricted(@Suppress("UNUSED_PARAMETER") item: StreamItem): Boolean = false
|
||||
|
||||
/**
|
||||
* Combined filter applied at row-emit. Returns the items to keep based
|
||||
* on the current Settings flags. Centralized here so the policy is
|
||||
* defined in one place; each calling LazyColumn just maps its source
|
||||
* list through this.
|
||||
*/
|
||||
fun applyContentFilters(
|
||||
items: List<StreamItem>,
|
||||
hideShorts: Boolean,
|
||||
hidePaid: Boolean = false,
|
||||
hideAgeRestricted: Boolean = false,
|
||||
): List<StreamItem> = items.filterNot { item ->
|
||||
(hideShorts && looksLikeShort(item)) ||
|
||||
(hidePaid && looksLikePaid(item)) ||
|
||||
(hideAgeRestricted && looksLikeAgeRestricted(item))
|
||||
}
|
||||
28
strawApp/src/main/kotlin/com/sulkta/straw/util/Coroutines.kt
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Coroutine-safe runCatching. Standard kotlin.runCatching catches
|
||||
* Throwable — including CancellationException, which is supposed to
|
||||
* propagate so structured concurrency works.
|
||||
* surfaced the bug: a runCatching around a channelInfo() call
|
||||
* inside a cancelled job swallowed the cancellation, the job
|
||||
* carried on past the runCatching, hit its terminal write fence
|
||||
* (which only checked URL-equality, so same-URL races couldn't
|
||||
* be distinguished), and clobbered the newer job's state.
|
||||
*
|
||||
* Always use this in coroutine bodies. The plain runCatching is
|
||||
* still fine in non-suspend code (no coroutine to cancel).
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.util
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
inline fun <R> runCatchingCancellable(block: () -> R): Result<R> = try {
|
||||
Result.success(block())
|
||||
} catch (c: CancellationException) {
|
||||
throw c
|
||||
} catch (t: Throwable) {
|
||||
Result.failure(t)
|
||||
}
|
||||
|
|
@ -25,3 +25,19 @@ fun formatDuration(sec: Long): String {
|
|||
val s = sec % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick "12s ago" / "3m ago" / "5h ago" / "2d ago" for the auto-update
|
||||
* "Last checked" timestamp. Future timestamps (clock skew) return the
|
||||
* just-now bucket.
|
||||
*/
|
||||
fun formatRelativeSince(ms: Long, nowMs: Long = System.currentTimeMillis()): String {
|
||||
val delta = (nowMs - ms).coerceAtLeast(0L)
|
||||
val sec = delta / 1000
|
||||
return when {
|
||||
sec < 60 -> "${sec}s ago"
|
||||
sec < 3600 -> "${sec / 60}m ago"
|
||||
sec < 86_400 -> "${sec / 3600}h ago"
|
||||
else -> "${sec / 86_400}d ago"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Strip HTML tags from NewPipeExtractor's description.content for plain-text
|
||||
* rendering. Day-3 polish replaces this with a real Markwon/Compose annotated
|
||||
* renderer; for now we just want readable text.
|
||||
* Strip HTML tags from video descriptions for plain-text rendering.
|
||||
* Replace with a real annotated renderer (Markwon, Compose annotated
|
||||
* strings) when the description UI needs richer formatting.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.util
|
||||
|
|
|
|||
136
strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Capture this process's logcat into a file and return a share Intent.
|
||||
* Used from the Settings → "Export logs" action so users can attach a
|
||||
* log dump when reporting a problem.
|
||||
*
|
||||
* SECURITY: The dump is filtered before being written to disk —
|
||||
* pre-signed googlevideo URLs, OAuth-style tokens, and anything
|
||||
* matching the leak patterns below get scrubbed line-by-line. Without
|
||||
* this, a user reporting a bug to Telegram would hand the chooser app
|
||||
* their currently-playing session-bound streaming credentials.
|
||||
*
|
||||
* Android also limits logcat-via-Runtime.exec to the calling app's
|
||||
* own UID on API 30+, so this captures Straw's own log lines only.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Process
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object LogDump {
|
||||
|
||||
/**
|
||||
* Pull recent logcat, scrub sensitive substrings, write to a file
|
||||
* in cacheDir, return a share-able Intent. Suspend so callers can
|
||||
* stay off the main thread — `proc.waitFor()` plus a multi-MB
|
||||
* `copyTo` is firmly an IO operation.
|
||||
*/
|
||||
suspend fun capture(context: Context): Result<Intent> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val pid = Process.myPid()
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||
// Write to cacheDir/logs/
|
||||
// narrowed the FileProvider scope from the whole cacheDir
|
||||
// to just this subdir, so dumps must land here.
|
||||
val logsDir = File(context.cacheDir, "logs").apply { mkdirs() }
|
||||
val outFile = File(logsDir, "straw-logs-$timestamp.txt")
|
||||
val tmpFile = File(logsDir, "straw-logs-$timestamp.txt.tmp")
|
||||
|
||||
// Sweep old dumps before writing the new one so cacheDir
|
||||
// doesn't grow per export.
|
||||
logsDir.listFiles { _, name ->
|
||||
name.startsWith("straw-logs-") && (name.endsWith(".txt") || name.endsWith(".tmp"))
|
||||
}?.forEach { it.delete() }
|
||||
|
||||
// -d dump-and-exit (no follow), -v threadtime is the
|
||||
// most-greppable format, --pid filter restricts to our
|
||||
// process so we don't exfiltrate sibling apps' chatter.
|
||||
val cmd = arrayOf("logcat", "-d", "-v", "threadtime", "--pid=$pid")
|
||||
val proc = ProcessBuilder(*cmd).redirectErrorStream(true).start()
|
||||
tmpFile.bufferedWriter().use { out ->
|
||||
proc.inputStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
out.write(scrubLine(line))
|
||||
out.newLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
val exit = proc.waitFor()
|
||||
if (exit != 0) {
|
||||
tmpFile.delete()
|
||||
throw IOException("logcat exit=$exit")
|
||||
}
|
||||
if (tmpFile.length() == 0L) {
|
||||
tmpFile.delete()
|
||||
throw IOException("logcat produced 0 bytes (sandbox restriction?)")
|
||||
}
|
||||
// Atomic-ish: only rename to final name on full success.
|
||||
if (!tmpFile.renameTo(outFile)) {
|
||||
tmpFile.delete()
|
||||
throw IOException("rename failed")
|
||||
}
|
||||
|
||||
val authority = "${context.packageName}.fileprovider"
|
||||
val uri: Uri = FileProvider.getUriForFile(context, authority, outFile)
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "Straw logs $timestamp")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-redact known credential-shaped substrings before they hit
|
||||
* disk. Cheap line-level pass — adversarial-perfect would need a
|
||||
* URL parser, but the regex approach catches every documented
|
||||
* leak vector at zero allocation cost.
|
||||
*
|
||||
* Public so error-handler call sites (PlayerScreen / VideoDetail
|
||||
* `playbackError`) can scrub Media3's `PlaybackException.message`
|
||||
* before rendering it to the user — that string includes the full
|
||||
* request URI for HttpDataSource exceptions, which would otherwise
|
||||
* be a leak via screenshot.
|
||||
*/
|
||||
fun scrubLine(line: String): String {
|
||||
var s = line
|
||||
// Pre-signed googlevideo URLs: keep host visible, drop path+query.
|
||||
s = GOOGLEVIDEO_URL_RE.replace(s, "https://<host>.googlevideo.com/<scrubbed>")
|
||||
// Long, distinctive token names — match anywhere.
|
||||
s = SIGNED_PARAM_LONG_RE.replace(s, "$1=<scrubbed>")
|
||||
// Short single-letter / two-letter tokens — require `[?&]`
|
||||
// immediately before to avoid eating innocent counters.
|
||||
s = SIGNED_PARAM_SHORT_RE.replace(s, "$1$2=<scrubbed>")
|
||||
return s
|
||||
}
|
||||
|
||||
private val GOOGLEVIDEO_URL_RE = Regex(
|
||||
"""https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""",
|
||||
)
|
||||
// Long tokens are unique enough to match anywhere. Short tokens
|
||||
// (n, mn, ms, mo, pl, ip, ei) require `[?&]` immediately before
|
||||
// so we don't redact innocuous `n=42` counters from other libs.
|
||||
private val SIGNED_PARAM_LONG_RE = Regex(
|
||||
"""\b(signature|sparams|lsig|cpn|expire|pot|sig|key)=([^&\s"']+)""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
private val SIGNED_PARAM_SHORT_RE = Regex(
|
||||
"""([?&])(n|mn|ms|mo|pl|ip|ei)=([^&\s"']+)""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* On-disk usage helper for the Settings → Storage section. Reads the
|
||||
* actual .xml file size for each SharedPreferences-backed store + the
|
||||
* Coil disk-cache size, so the user can see what's eating space rather
|
||||
* than guessing from cap settings.
|
||||
*
|
||||
* All values are best-effort: a missing file (store never written)
|
||||
* returns 0; permission/IO errors return 0 and log silently. The
|
||||
* displayed numbers are advisory, not authoritative.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.util
|
||||
|
||||
import android.content.Context
|
||||
import coil3.SingletonImageLoader
|
||||
import java.io.File
|
||||
|
||||
object StorageUsage {
|
||||
/**
|
||||
* Bytes-on-disk for a SharedPreferences file. The Android framework
|
||||
* writes `<dataDir>/shared_prefs/<prefsName>.xml`. dataDir is
|
||||
* `context.applicationInfo.dataDir` (the parent of filesDir,
|
||||
* approximately).
|
||||
*/
|
||||
fun sharedPrefBytes(context: Context, prefsName: String): Long {
|
||||
val dataDir = context.applicationInfo.dataDir ?: return 0L
|
||||
val f = File(dataDir, "shared_prefs/$prefsName.xml")
|
||||
return if (f.exists()) f.length() else 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Coil's disk cache total. Returns 0 if Coil hasn't lazily
|
||||
* initialized a disk cache yet (no images loaded this session).
|
||||
*/
|
||||
fun coilDiskCacheBytes(context: Context): Long = runCatching {
|
||||
SingletonImageLoader.get(context).diskCache?.size ?: 0L
|
||||
}.getOrDefault(0L)
|
||||
|
||||
/** Human-friendly rendering: "4.2 KB" / "13 MB" / "—" for 0. */
|
||||
fun format(bytes: Long): String {
|
||||
if (bytes <= 0L) return "—"
|
||||
val kb = bytes / 1024.0
|
||||
if (kb < 1024.0) return "%.1f KB".format(kb)
|
||||
val mb = kb / 1024.0
|
||||
if (mb < 1024.0) return "%.1f MB".format(mb)
|
||||
return "%.2f GB".format(mb / 1024.0)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* NewPipeExtractor returns thumbnails as a List<Image> with width/height
|
||||
* fields. Calling .firstOrNull() picks the smallest (the list is sorted
|
||||
* ascending) — which gave us pixelated thumbnails. This helper picks the
|
||||
* largest by pixel area instead.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.util
|
||||
|
||||
import org.schabi.newpipe.extractor.Image
|
||||
|
||||
fun bestThumbnail(images: List<Image>?): String? {
|
||||
if (images.isNullOrEmpty()) return null
|
||||
return images
|
||||
.maxByOrNull {
|
||||
val w = it.width.takeIf { v -> v > 0 } ?: 0
|
||||
val h = it.height.takeIf { v -> v > 0 } ?: 0
|
||||
w.toLong() * h.toLong()
|
||||
}
|
||||
?.url
|
||||
}
|
||||
30
strawApp/src/main/kotlin/com/sulkta/straw/util/YtUrl.kt
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Shared YouTube-host allowlist. Originally lived inside
|
||||
* SettingsImport for the import-time URL check, then two more call
|
||||
* sites — VideoDetailViewModel's auto channelInfo(uploaderUrl) and
|
||||
* recordWatch persistence — needed the same gate. Co-locating the
|
||||
* set here so a future host (yewtu.be, hypothetical YT mirror) is
|
||||
* one edit instead of three.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.util
|
||||
|
||||
private val ALLOWED_YT_HOSTS: Set<String> = setOf(
|
||||
"youtube.com", "www.youtube.com", "m.youtube.com",
|
||||
"music.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com",
|
||||
"youtu.be",
|
||||
)
|
||||
|
||||
fun isAllowedYtUrl(url: String): Boolean {
|
||||
val uri = runCatching { java.net.URI(url) }.getOrNull() ?: return false
|
||||
// Require an http/https scheme — `//host/...` (schemeless) and
|
||||
// `mailto:host` both parse with a host attribute.
|
||||
val scheme = uri.scheme?.lowercase() ?: return false
|
||||
if (scheme != "https" && scheme != "http") return false
|
||||
// Strip a single trailing dot (RFC FQDN form) before lookup.
|
||||
val host = uri.host?.lowercase()?.removeSuffix(".") ?: return false
|
||||
return host in ALLOWED_YT_HOSTS
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#166534" />
|
||||
</shape>
|
||||
17
strawApp/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Straw adaptive icon foreground.
|
||||
Canvas is 108x108dp; the visible mask-safe area is roughly the central
|
||||
66x66dp box centered on (54,54). Keep the play triangle inside that.
|
||||
One bold white play triangle on the deep-green ic_launcher_background.
|
||||
Single strong silhouette reads cleanly at tiny launcher sizes.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M 38,30 L 38,78 L 82,54 Z" />
|
||||
</vector>
|
||||
5
strawApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
strawApp/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 603 B |
BIN
strawApp/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 603 B |
BIN
strawApp/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 479 B |
BIN
strawApp/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 479 B |
BIN
strawApp/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 759 B |
BIN
strawApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 759 B |
BIN
strawApp/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 973 B |
BIN
strawApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 973 B |
BIN
strawApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
strawApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
28
strawApp/src/main/res/xml/data_extraction_rules.xml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Block both cloud auto-backup (`cloud-backup`) and direct device-to-
|
||||
device transfers (`device-transfer`) for every Straw storage scope.
|
||||
Watch history, search history, full subscription list, and the on-
|
||||
disk feed/search caches would otherwise sync silently to the user's
|
||||
Google account and ride to any restored device.
|
||||
|
||||
We don't WANT this content backed up — there's no account model;
|
||||
there's nothing to recover. Better to ask the user to re-subscribe
|
||||
than to leak their entire video-watching profile to Google Drive.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
17
strawApp/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- LogDump shares logcat captures to a chooser-picked app.
|
||||
Narrowed to a `logs/` subdir of cacheDir (was the entire
|
||||
cacheDir) so a future bug that builds an attacker-influenced
|
||||
FileProvider URI can't reach SettingsImport workdirs or
|
||||
other cache state. -->
|
||||
<cache-path name="logs" path="logs/" />
|
||||
|
||||
<!-- Completed downloads. Downloader uses
|
||||
setDestinationInExternalFilesDir(DIRECTORY_MOVIES + "/audio" |
|
||||
"/video"), so the FileProvider needs to be able to map those
|
||||
paths back to a content:// URI when DownloadsScreen taps to
|
||||
open the finished file. -->
|
||||
<external-files-path name="downloads-audio" path="Movies/audio/" />
|
||||
<external-files-path name="downloads-video" path="Movies/video/" />
|
||||
</paths>
|
||||