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:
parent
c2c5536729
commit
89b9837758
8 changed files with 258 additions and 64 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
190
rust/strawcore/src/stream.rs
Normal file
190
rust/strawcore/src/stream.rs
Normal 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue