diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 0cd1236e6..e54bd5609 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/rust/strawcore/src/lib.rs b/rust/strawcore/src/lib.rs index a545515dc..ac9ade687 100644 --- a/rust/strawcore/src/lib.rs +++ b/rust/strawcore/src/lib.rs @@ -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. diff --git a/rust/strawcore/src/stream.rs b/rust/strawcore/src/stream.rs index a8b5d1a3e..233550c54 100644 --- a/rust/strawcore/src/stream.rs +++ b/rust/strawcore/src/stream.rs @@ -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, + pub audio_url: Option, + pub combined_url: Option, + pub dash_mpd_url: Option, + pub hls_url: Option, +} + #[uniffi::export(async_runtime = "tokio")] pub async fn stream_info(input: String) -> Result { 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 { + 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 { + 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()) +} diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index 42fc4ec5a..411446b22 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -67,8 +67,15 @@ configure { // 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( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt index d3daaac3e..97c98cfa1 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt @@ -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 = emptyList(), ): ResolvedPlayback { - val maxRes = Settings.get().maxResolution.value.ceiling - fun pickVideo(streams: List): String? { - if (streams.isEmpty()) return null - val capped = streams.filter { it.height <= maxRes } - return if (capped.isNotEmpty()) { - capped.maxByOrNull { it.bitrate }?.url - } else { - streams.minByOrNull { it.height }?.url - } - } + 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, ) }