v0.1.0-V (vc=9): U-3 — streamInfo via rustypipe drives VideoDetail+Player

stream_info(url) UniFFI suspend fn replaces NewPipeExtractor's
StreamInfo.getInfo() for both VideoDetailViewModel and PlayerViewModel.
One Rust round-trip drives the detail screen render AND the player's
resolve(). The VideoDetailUiState.info field cached on detail load is
reused by the Download dialog so we don't refetch.

Deferred to U-3.5:
- like_count (rustypipe's player() doesn't surface engagement data;
  a separate query is needed)
- related (player() doesn't include 'up next'; comes from a separate
  endpoint). Kotlin gets empty list for now — RelatedRow handles it.

Type quirks vs my initial guesses (caught by cargo check):
- details.duration is u32, not Option<u32>
- channel is split into channel_id + channel_name, not a struct
- like_count doesn't exist at this query depth
- VideoFormat::Webm (lowercase mb), VideoCodec::Avc1 (not H264)
- video_only is a separate vec (video_only_streams), not a bool flag
This commit is contained in:
Sulkta 2026-05-24 08:52:43 -07:00
parent c2c5536729
commit 89b9837758
8 changed files with 258 additions and 64 deletions

View file

@ -15,6 +15,6 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw // Sulkta fork — Straw
const val STRAW_VERSION_CODE = 8 const val STRAW_VERSION_CODE = 9
const val STRAW_VERSION_NAME = "0.1.0-U" const val STRAW_VERSION_NAME = "0.1.0-V"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -24,6 +24,10 @@ codegen-units = 1
panic = "abort" panic = "abort"
opt-level = "z" opt-level = "z"
# `url` crate for video-id extraction in stream.rs.
[workspace.dependencies]
url = "2"
[profile.dev] [profile.dev]
# Keep debug builds fast — we're rebuilding constantly during U-1..U-5. # Keep debug builds fast — we're rebuilding constantly during U-1..U-5.
opt-level = 0 opt-level = 0

View file

@ -31,6 +31,8 @@ rquickjs-sys = { version = "0.9", default-features = false, features = ["bindgen
thiserror = "1" thiserror = "1"
# Single-threaded init for the runtime + extractor singletons. # Single-threaded init for the runtime + extractor singletons.
once_cell = "1" once_cell = "1"
# URL parsing for the video-id extractor in stream.rs.
url = { workspace = true }
# Android log integration — `log::info!()` ends up in `adb logcat -s strawcore`. # Android log integration — `log::info!()` ends up in `adb logcat -s strawcore`.
log = "0.4" log = "0.4"
android_logger = { version = "0.14", default-features = false } android_logger = { version = "0.14", default-features = false }

View file

@ -11,6 +11,7 @@ use std::sync::Once;
mod error; mod error;
mod runtime; mod runtime;
mod search; mod search;
mod stream;
#[allow(unused_imports)] #[allow(unused_imports)]
use runtime::block_on; use runtime::block_on;
@ -18,6 +19,7 @@ use runtime::block_on;
// Re-exports so UniFFI sees the types at the crate root for macro discovery. // Re-exports so UniFFI sees the types at the crate root for macro discovery.
pub use error::StrawcoreError; pub use error::StrawcoreError;
pub use search::SearchItem; pub use search::SearchItem;
pub use stream::{AudioStreamItem, StreamInfo, VideoStreamItem};
/// Initialize Android logging once. The Kotlin side calls this from /// Initialize Android logging once. The Kotlin side calls this from
/// StrawApp.onCreate() so anything emitted via `log::info!()` shows up in /// StrawApp.onCreate() so anything emitted via `log::info!()` shows up in

View file

@ -0,0 +1,190 @@
// Phase U-3 — `stream_info(url)` via rustypipe, exposed as a suspend fun.
//
// Drives both VideoDetailScreen (title/uploader/description/thumbnail) and
// PlayerScreen (audio/video stream URLs that ExoPlayer loads from). One
// Rust call replaces two NewPipeExtractor StreamInfo.getInfo() round-trips.
//
// `StreamInfo` keeps field names parallel to the Kotlin-side VideoDetail
// + ResolvedPlayback so the ViewModels swap one-to-one.
//
// Not yet wired here (rustypipe doesn't surface these from `player()` alone
// and they need a separate fetch):
// - like_count
// - related videos
// Both will land in U-3.5 via `rp.query().video_details(id)` if we want
// the like count, and via a separate "related" call. For now Kotlin gets
// 0 / empty list and the UI handles it (already does).
use crate::error::StrawcoreError;
use crate::search::SearchItem;
use rustypipe::client::RustyPipe;
#[derive(Debug, Clone, uniffi::Record)]
pub struct StreamInfo {
pub id: String,
pub title: String,
pub uploader: String,
pub uploader_url: Option<String>,
pub description: String,
pub thumbnail: Option<String>,
pub view_count: i64,
pub like_count: i64,
/// Duration in seconds. 0 = live/unknown.
pub duration_seconds: i64,
/// Progressive (audio+video combined). Empty when YT only serves DASH.
pub combined: Vec<VideoStreamItem>,
/// DASH/adaptive video-only streams. Pair with `audio_only` via MergingMediaSource.
pub video_only: Vec<VideoStreamItem>,
/// DASH/adaptive audio-only streams. Sort by bitrate desc for "best audio".
pub audio_only: Vec<AudioStreamItem>,
/// Optional DASH MPD URL. ExoPlayer's DashMediaSource accepts this directly.
pub dash_mpd_url: Option<String>,
/// Optional HLS playlist URL. ExoPlayer's HlsMediaSource accepts this directly.
pub hls_url: Option<String>,
/// "Up next" list. Empty for now — populated in U-3.5.
pub related: Vec<SearchItem>,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct VideoStreamItem {
pub url: String,
/// e.g. 1080, 720, 480.
pub height: i32,
pub bitrate: i64,
pub mime_type: String,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct AudioStreamItem {
pub url: String,
pub bitrate: i64,
pub mime_type: String,
}
fn yt_channel_url(id: &str) -> String {
format!("https://www.youtube.com/channel/{}", id)
}
/// Best-effort YouTube video-id extraction.
fn extract_video_id(input: &str) -> Result<String, StrawcoreError> {
let trimmed = input.trim();
if trimmed.len() == 11
&& trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Ok(trimmed.to_string());
}
let url = url::Url::parse(trimmed).map_err(|e| StrawcoreError::Unsupported {
detail: format!("bad URL: {}", e),
})?;
let host = url.host_str().unwrap_or("").to_ascii_lowercase();
let host = host
.trim_start_matches("www.")
.trim_start_matches("m.")
.trim_start_matches("music.");
match host {
"youtube.com" | "youtube-nocookie.com" => {
if let Some(v) = url
.query_pairs()
.find(|(k, _)| k == "v")
.map(|(_, v)| v.into_owned())
{
if !v.is_empty() {
return Ok(v);
}
}
let path = url.path().trim_start_matches('/');
for prefix in ["embed/", "v/", "shorts/"] {
if let Some(rest) = path.strip_prefix(prefix) {
let id = rest.split('/').next().unwrap_or("");
if !id.is_empty() {
return Ok(id.to_string());
}
}
}
Err(StrawcoreError::Unsupported {
detail: "no video id in URL".into(),
})
}
"youtu.be" => {
let id = url.path().trim_start_matches('/').split('/').next().unwrap_or("");
if id.is_empty() {
Err(StrawcoreError::Unsupported {
detail: "no video id in youtu.be URL".into(),
})
} else {
Ok(id.to_string())
}
}
_ => Err(StrawcoreError::Unsupported {
detail: format!("unsupported host: {}", host),
}),
}
}
#[uniffi::export(async_runtime = "tokio")]
pub async fn stream_info(url: String) -> Result<StreamInfo, StrawcoreError> {
let id = extract_video_id(&url)?;
log::info!("strawcore::stream_info id={}", id);
let rp = RustyPipe::new();
let player = rp.query().player(&id).await?;
let details = &player.details;
// Progressive (combined audio+video) goes through video_streams; the
// audio+video split path is video_only_streams + audio_streams.
let combined: Vec<VideoStreamItem> = player
.video_streams
.iter()
.map(|s| VideoStreamItem {
url: s.url.clone(),
height: s.height as i32,
bitrate: s.bitrate as i64,
mime_type: format!("{:?}/{:?}", s.format, s.codec),
})
.collect();
let video_only: Vec<VideoStreamItem> = player
.video_only_streams
.iter()
.map(|s| VideoStreamItem {
url: s.url.clone(),
height: s.height as i32,
bitrate: s.bitrate as i64,
mime_type: format!("{:?}/{:?}", s.format, s.codec),
})
.collect();
let audio_only: Vec<AudioStreamItem> = player
.audio_streams
.iter()
.map(|s| AudioStreamItem {
url: s.url.clone(),
bitrate: s.bitrate as i64,
mime_type: format!("{:?}/{:?}", s.format, s.codec),
})
.collect();
let thumbnail = details.thumbnail.last().map(|t| t.url.clone());
Ok(StreamInfo {
id: details.id.clone(),
title: details.name.clone().unwrap_or_default(),
uploader: details.channel_name.clone().unwrap_or_default(),
uploader_url: if details.channel_id.is_empty() {
None
} else {
Some(yt_channel_url(&details.channel_id))
},
description: details.description.clone().unwrap_or_default(),
thumbnail,
view_count: details.view_count.unwrap_or(0) as i64,
like_count: 0,
duration_seconds: details.duration as i64,
combined,
video_only,
audio_only,
dash_mpd_url: player.dash_manifest_url.clone(),
hls_url: player.hls_manifest_url.clone(),
related: Vec::new(),
})
}

View file

@ -190,7 +190,7 @@ fun VideoDetailScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if (showDownloadDialog) { if (showDownloadDialog) {
val info = state.streamInfo val info = state.info // uniffi.strawcore.StreamInfo cached on the UI state
AlertDialog( AlertDialog(
onDismissRequest = { showDownloadDialog = false }, onDismissRequest = { showDownloadDialog = false },
title = { Text("Download") }, title = { Text("Download") },
@ -208,10 +208,9 @@ fun VideoDetailScreen(
confirmButton = { confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { Button(onClick = {
val audio = info?.audioStreams val audio = info?.audioOnly
?.filter { it.content?.isNotBlank() == true } ?.maxByOrNull { it.bitrate }
?.maxByOrNull { it.bitrate ?: 0 } ?.url
?.content
if (audio != null) { if (audio != null) {
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio) val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
val msg = if (id > 0) "audio queued" else "download refused (bad URL)" val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
@ -222,14 +221,12 @@ fun VideoDetailScreen(
showDownloadDialog = false showDownloadDialog = false
}) { Text("Audio") } }) { Text("Audio") }
Button(onClick = { Button(onClick = {
val video = info?.videoStreams val video = info?.combined
?.filter { it.content?.isNotBlank() == true } ?.maxByOrNull { it.bitrate }
?.maxByOrNull { it.bitrate ?: 0 } ?.url
?.content ?: info?.videoOnly
?: info?.videoOnlyStreams ?.maxByOrNull { it.bitrate }
?.filter { it.content?.isNotBlank() == true } ?.url
?.maxByOrNull { it.bitrate ?: 0 }
?.content
if (video != null) { if (video != null) {
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video) val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
val msg = if (id > 0) "video queued" else "download refused (bad URL)" val msg = if (id > 0) "video queued" else "download refused (bad URL)"

View file

@ -1,6 +1,11 @@
/* /*
* SPDX-FileCopyrightText: 2026 Sulkta * SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase U-3: extractor moved from NewPipeExtractor (Java) to strawcore
* (Rust + rustypipe), called as a UniFFI suspend fun. The shape of
* VideoDetail and the on-screen behavior are unchanged; only the engine
* underneath flipped.
*/ */
package com.sulkta.straw.feature.detail package com.sulkta.straw.feature.detail
@ -10,18 +15,16 @@ import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.History import com.sulkta.straw.data.History
import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.WatchHistoryItem import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.net.RydClient import com.sulkta.straw.net.RydClient
import com.sulkta.straw.net.RydVotes import com.sulkta.straw.net.RydVotes
import com.sulkta.straw.net.SponsorBlockClient import com.sulkta.straw.net.SponsorBlockClient
import com.sulkta.straw.util.bestThumbnail
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class VideoDetail( data class VideoDetail(
val id: String, val id: String,
@ -33,15 +36,15 @@ data class VideoDetail(
val thumbnail: String?, val thumbnail: String?,
val ryd: RydVotes? = null, val ryd: RydVotes? = null,
val sbSegmentCount: Int = 0, val sbSegmentCount: Int = 0,
val related: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(), val related: List<StreamItem> = emptyList(),
) )
data class VideoDetailUiState( data class VideoDetailUiState(
val loading: Boolean = true, val loading: Boolean = true,
val detail: VideoDetail? = null, val detail: VideoDetail? = null,
val error: String? = null, val error: String? = null,
// Stored on success for handoff to player. Not in UI. /** Cached strawcore result so the Player + Download dialog can use it. */
val streamInfo: StreamInfo? = null, val info: uniffi.strawcore.StreamInfo? = null,
) )
class VideoDetailViewModel : ViewModel() { class VideoDetailViewModel : ViewModel() {
@ -51,18 +54,18 @@ class VideoDetailViewModel : ViewModel() {
private var loadedUrl: String? = null private var loadedUrl: String? = null
fun load(streamUrl: String) { fun load(streamUrl: String) {
// viewModel() is Activity-scoped, so the same VM is reused across // Activity-scoped VM is reused across nav entries; only re-fetch when
// navigations. Compare the requested URL with what we last loaded. // the requested URL actually changed.
if (loadedUrl == streamUrl && _ui.value.detail != null) return if (loadedUrl == streamUrl && _ui.value.detail != null) return
loadedUrl = streamUrl loadedUrl = streamUrl
_ui.value = VideoDetailUiState(loading = true) _ui.value = VideoDetailUiState(loading = true)
viewModelScope.launch { viewModelScope.launch {
try { try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) } val info = uniffi.strawcore.streamInfo(streamUrl)
val videoId = info.id val videoId = info.id
val thumb = bestThumbnail(info.thumbnails) val title = info.title.ifBlank { "(no title)" }
val title = info.name ?: "(no title)" val uploader = info.uploader
val uploader = info.uploaderName ?: "" val thumb = info.thumbnail
runCatching { runCatching {
History.get().recordWatch( History.get().recordWatch(
@ -77,6 +80,8 @@ class VideoDetailViewModel : ViewModel() {
) )
} }
// RYD + SponsorBlock stay in Kotlin (small JSON HTTP clients,
// no extractor logic).
val ryd = withContext(Dispatchers.IO) { val ryd = withContext(Dispatchers.IO) {
runCatching { RydClient.fetch(videoId) }.getOrNull() runCatching { RydClient.fetch(videoId) }.getOrNull()
} }
@ -84,19 +89,18 @@ class VideoDetailViewModel : ViewModel() {
val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) { val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) {
runCatching { SponsorBlockClient.fetch(videoId, sbCats).size }.getOrDefault(0) runCatching { SponsorBlockClient.fetch(videoId, sbCats).size }.getOrDefault(0)
} }
val related = info.relatedItems
?.filterIsInstance<StreamInfoItem>() val related = info.related.map { r ->
?.map { it -> StreamItem(
com.sulkta.straw.feature.search.StreamItem( url = r.url,
url = it.url, title = r.title.ifBlank { "(no title)" },
title = it.name ?: "(no title)", uploader = r.uploader,
uploader = it.uploaderName ?: "", uploaderUrl = r.uploaderUrl,
uploaderUrl = it.uploaderUrl, thumbnail = r.thumbnail,
thumbnail = bestThumbnail(it.thumbnails), durationSeconds = r.durationSeconds,
durationSeconds = it.duration, viewCount = r.viewCount,
viewCount = it.viewCount, )
) }
} ?: emptyList()
_ui.value = VideoDetailUiState( _ui.value = VideoDetailUiState(
loading = false, loading = false,
@ -106,13 +110,13 @@ class VideoDetailViewModel : ViewModel() {
uploader = uploader, uploader = uploader,
uploaderUrl = info.uploaderUrl, uploaderUrl = info.uploaderUrl,
viewCount = info.viewCount, viewCount = info.viewCount,
description = info.description?.content ?: "", description = info.description,
thumbnail = thumb, thumbnail = thumb,
ryd = ryd, ryd = ryd,
sbSegmentCount = sbCount, sbSegmentCount = sbCount,
related = related, related = related,
), ),
streamInfo = info, info = info,
) )
} catch (t: Throwable) { } catch (t: Throwable) {
_ui.value = VideoDetailUiState( _ui.value = VideoDetailUiState(

View file

@ -1,13 +1,16 @@
/* /*
* SPDX-FileCopyrightText: 2026 Sulkta * SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase U-3: extractor moved from NewPipeExtractor (Java) to strawcore
* (Rust + rustypipe via UniFFI). PlayerScreen still calls vm.resolve(url)
* the same way the engine underneath flipped.
*/ */
package com.sulkta.straw.feature.player package com.sulkta.straw.feature.player
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.MaxResolution
import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Settings
import com.sulkta.straw.net.SbSegment import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.net.SponsorBlockClient import com.sulkta.straw.net.SponsorBlockClient
@ -17,7 +20,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.stream.StreamInfo
data class ResolvedPlayback( data class ResolvedPlayback(
val title: String, val title: String,
@ -48,8 +50,9 @@ class PlayerViewModel : ViewModel() {
_ui.value = PlayerUiState(loading = true) _ui.value = PlayerUiState(loading = true)
viewModelScope.launch { viewModelScope.launch {
try { try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) } val info = uniffi.strawcore.streamInfo(streamUrl)
val videoId = info.id val videoId = info.id
val sbCategories = Settings.get().sbCategories.value.map { it.key } val sbCategories = Settings.get().sbCategories.value.map { it.key }
val segments = if (sbCategories.isEmpty()) { val segments = if (sbCategories.isEmpty()) {
emptyList() emptyList()
@ -61,32 +64,24 @@ class PlayerViewModel : ViewModel() {
} }
val maxRes = Settings.get().maxResolution.value.ceiling 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 // Audit HIGH-8 carry-over: filter by max resolution but fall
// (e.g. user picked 144p but the video only has 360p+), fall // back to lowest available if the ceiling excludes everything.
// back to the lowest-resolution available instead of returning fun pickVideo(streams: List<uniffi.strawcore.VideoStreamItem>): String? {
// null and showing a black-screen player. if (streams.isEmpty()) return null
fun pickVideo(streams: List<org.schabi.newpipe.extractor.stream.VideoStream>?): String? { val filtered = streams.filter { it.height <= maxRes }
if (streams.isNullOrEmpty()) return null val pool = filtered.ifEmpty { streams }
val withContent = streams.filter { it.content?.isNotBlank() == true } return pool.maxByOrNull { it.bitrate }?.url
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 combined = pickVideo(info.combined)
val videoOnly = pickVideo(info.videoOnlyStreams) val videoOnly = pickVideo(info.videoOnly)
val audioOnly = info.audioStreams val audioOnly = info.audioOnly.maxByOrNull { it.bitrate }?.url
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
_ui.value = PlayerUiState( _ui.value = PlayerUiState(
loading = false, loading = false,
resolved = ResolvedPlayback( resolved = ResolvedPlayback(
title = info.name ?: "", title = info.title,
videoUrl = videoOnly, videoUrl = videoOnly,
audioUrl = audioOnly, audioUrl = audioOnly,
combinedUrl = combined, combinedUrl = combined,