vc=84 — rename launcher to 'Straw' + move stream picker into Rust
All checks were successful
build-apk / build-and-publish (push) Successful in 7m17s
gitleaks / scan (push) Successful in 43s

Two changes:

1. Launcher name is now just 'Straw', not 'Straw debug' — past the
   debug-branding phase. Kept the .debug applicationId suffix (package
   stays com.sulkta.straw.debug) on purpose so fdroid updates install in
   place and the in-app auto-updater keeps working; dropping the suffix
   would change the package id and force a reinstall that wipes everyone's
   subs/history. That's a separate, deliberate release-track cutover.

2. Stream-selection logic moved out of Kotlin (resolveStreamPlayback) into
   the Rust strawcore wrapper as resolve_playback(StreamInfo, max_height)
   -> ResolvedStreams. The app keeps a thin shim that supplies the
   resolution cap (Settings.maxResolution) and attaches SponsorBlock
   segments. Byte-for-byte behavior parity with the old Kotlin picker:
   highest-bitrate stream at/under the cap, lowest-height fallback when
   nothing fits, first-element-wins on ties (matching Kotlin
   maxByOrNull/minByOrNull, not Rust's last-on-ties max_by_key), and
   isNotBlank() handling for the DASH/HLS URLs. First step of moving all
   backend logic to Rust; the picking lives at the FFI boundary because it
   depends on an app setting, keeping strawcore-core a pure extractor.

Wrapper cargo check + clippy clean (no new warnings); FFI surface adds
ResolvedStreams + resolvePlayback, bindings regen at build.
This commit is contained in:
Cobb 2026-06-21 11:41:50 -07:00
parent ea82ba765a
commit addd074f61
5 changed files with 127 additions and 20 deletions

View file

@ -9,6 +9,20 @@ const val STRAW_SDK_TARGET = 35
// Sulkta fork — Straw
//
// vc=84 / 0.1.0-CR — name + stream-picker → Rust:
// * The app is now just "Straw" in the launcher, not "Straw debug" —
// we're past the debug-branding phase. (The package id stays
// com.sulkta.straw.debug under the hood so updates install in place
// and nobody loses their subscriptions/history; dropping the suffix
// for real is a separate release-track cutover.)
// * The stream-selection logic (pick the playable video/audio/combined
// URLs from an extraction, honoring the resolution cap) moved out of
// Kotlin into the Rust strawcore layer (resolvePlayback). The Kotlin
// side is now a thin shim that supplies the cap and attaches
// SponsorBlock segments. Behavior is identical — same URLs picked,
// same data-saver fallback — it's just backend logic living in Rust
// where it belongs. First step of moving all backend logic to Rust.
//
// vc=83 / 0.1.0-CQ — fix: swapping videos from the minibar kept playing
// the old one:
// * With a video minimized to the bottom minibar, picking a different
@ -164,6 +178,6 @@ const val STRAW_SDK_TARGET = 35
// 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 = 83
const val STRAW_VERSION_NAME = "0.1.0-CQ"
const val STRAW_VERSION_CODE = 84
const val STRAW_VERSION_NAME = "0.1.0-CR"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -21,7 +21,7 @@ mod stream;
pub use channel::ChannelInfo;
pub use error::StrawcoreError;
pub use search::{Page, SearchItem};
pub use stream::{AudioStreamItem, StreamInfo, VideoStreamItem};
pub use stream::{AudioStreamItem, ResolvedStreams, StreamInfo, VideoStreamItem};
/// Initialize Android logging + the strawcore-core HTTP downloader.
/// Kotlin calls this from StrawApp.onCreate(). Idempotent.

View file

@ -56,6 +56,22 @@ pub struct AudioStreamItem {
pub mime_type: String,
}
/// The player-facing URLs picked from a [`StreamInfo`]'s stream lists under
/// a resolution ceiling. This is the stream-selection policy that used to
/// live in the Kotlin `resolveStreamPlayback`; the app now keeps only a thin
/// shim that supplies the ceiling (its `Settings.maxResolution`) and attaches
/// SponsorBlock segments on top. SponsorBlock is deliberately NOT here — it's
/// a separate concern the app layers over this.
#[derive(Debug, Clone, uniffi::Record)]
pub struct ResolvedStreams {
pub title: String,
pub video_url: Option<String>,
pub audio_url: Option<String>,
pub combined_url: Option<String>,
pub dash_mpd_url: Option<String>,
pub hls_url: Option<String>,
}
#[uniffi::export(async_runtime = "tokio")]
pub async fn stream_info(input: String) -> Result<StreamInfo, StrawcoreError> {
log::info!("strawcore::stream_info input_len={}", input.len());
@ -170,3 +186,74 @@ fn audio_to_dto(a: strawcore_core::stream::AudioStream) -> AudioStreamItem {
mime_type: a.format.mime().to_string(),
}
}
/// Pick the player URLs from an already-fetched [`StreamInfo`]. Pure +
/// synchronous (no network). `max_height` is the app's
/// `Settings.maxResolution` ceiling — `Int.MAX_VALUE` means "no cap". This
/// is a byte-for-byte port of the prior Kotlin `resolveStreamPlayback`:
/// the app keeps a thin shim that passes the ceiling and tacks on
/// SponsorBlock segments.
#[uniffi::export]
pub fn resolve_playback(info: StreamInfo, max_height: i32) -> ResolvedStreams {
// Borrow the stream lists for selection before moving the owned String
// fields into the result struct (disjoint fields, so this is fine).
let video_url = pick_video(&info.video_only, max_height);
let audio_url = pick_audio(&info.audio_only);
let combined_url = pick_video(&info.combined, max_height);
ResolvedStreams {
title: info.title,
video_url,
audio_url,
combined_url,
// Kotlin used `takeIf { it.isNotBlank() }` — empty OR all-whitespace
// collapses to None.
dash_mpd_url: info.dash_mpd_url.filter(|s| !s.trim().is_empty()),
hls_url: info.hls_url.filter(|s| !s.trim().is_empty()),
}
}
/// Highest-bitrate video stream at or below `max_height`; if none fit, the
/// lowest-height stream (so a too-high-only set still plays without blowing
/// a data plan). The FIRST element wins ties — matching Kotlin's
/// `maxByOrNull`/`minByOrNull`, which keep the first max/min, unlike Rust's
/// `Iterator::max_by_key` (last on ties). Preserving that keeps the picked
/// URL identical to the previous Kotlin picker on same-bitrate/-height ties.
fn pick_video(streams: &[VideoStreamItem], max_height: i32) -> Option<String> {
if streams.is_empty() {
return None;
}
let mut best_capped: Option<&VideoStreamItem> = None;
for s in streams.iter().filter(|s| s.height <= max_height) {
match best_capped {
// Keep the existing pick unless this one is strictly higher
// bitrate — first-on-ties, matching `maxByOrNull`.
Some(b) if b.bitrate >= s.bitrate => {}
_ => best_capped = Some(s),
}
}
if let Some(s) = best_capped {
return Some(s.url.clone());
}
let mut lowest: Option<&VideoStreamItem> = None;
for s in streams {
match lowest {
// First-on-ties, matching `minByOrNull`.
Some(b) if b.height <= s.height => {}
_ => lowest = Some(s),
}
}
lowest.map(|s| s.url.clone())
}
/// Highest-bitrate audio stream, first-on-ties (matching Kotlin
/// `maxByOrNull { it.bitrate }`).
fn pick_audio(streams: &[AudioStreamItem]) -> Option<String> {
let mut best: Option<&AudioStreamItem> = None;
for s in streams {
match best {
Some(b) if b.bitrate >= s.bitrate => {}
_ => best = Some(s),
}
}
best.map(|s| s.url.clone())
}

View file

@ -67,8 +67,15 @@ configure<ApplicationExtension> {
// kotlinx-serialization companions.
debug {
isDebuggable = true
// Keep the `.debug` applicationId suffix (package stays
// com.sulkta.straw.debug) so in-place fdroid updates + the in-app
// auto-updater keep working and nobody loses their subs/history.
// But present as just "Straw" in the launcher — we're past the
// debug-phase branding. Dropping the suffix entirely is a
// separate, deliberate release-track cutover (it'd change the
// package id → force a reinstall + wipe app data).
applicationIdSuffix = ".debug"
resValue("string", "app_name", "Straw debug")
resValue("string", "app_name", "Straw")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(

View file

@ -32,6 +32,11 @@ fun extractYtVideoId(url: String): String? =
* 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.
*
* The stream-selection logic now lives in Rust (strawcore
* `resolvePlayback`) this is a thin shim that supplies the user's
* resolution ceiling and attaches the SponsorBlock `segments` (a
* separate, app-side concern) on top of the picked URLs.
*
* `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).
@ -40,23 +45,17 @@ 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
}
}
val picked = uniffi.strawcore.resolvePlayback(
info,
Settings.get().maxResolution.value.ceiling,
)
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() },
title = picked.title,
videoUrl = picked.videoUrl,
audioUrl = picked.audioUrl,
combinedUrl = picked.combinedUrl,
dashMpdUrl = picked.dashMpdUrl,
hlsUrl = picked.hlsUrl,
segments = segments,
)
}