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

@ -190,7 +190,7 @@ fun VideoDetailScreen(
Spacer(modifier = Modifier.height(16.dp))
if (showDownloadDialog) {
val info = state.streamInfo
val info = state.info // uniffi.strawcore.StreamInfo cached on the UI state
AlertDialog(
onDismissRequest = { showDownloadDialog = false },
title = { Text("Download") },
@ -208,10 +208,9 @@ 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)"
@ -222,14 +221,12 @@ 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)"

View file

@ -1,6 +1,11 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* 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
@ -10,18 +15,16 @@ import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.History
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.net.RydClient
import com.sulkta.straw.net.RydVotes
import com.sulkta.straw.net.SponsorBlockClient
import com.sulkta.straw.util.bestThumbnail
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
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class VideoDetail(
val id: String,
@ -33,15 +36,15 @@ data class VideoDetail(
val thumbnail: String?,
val ryd: RydVotes? = null,
val sbSegmentCount: Int = 0,
val related: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(),
val related: List<StreamItem> = emptyList(),
)
data class VideoDetailUiState(
val loading: Boolean = true,
val detail: VideoDetail? = null,
val error: String? = null,
// Stored on success for handoff to player. Not in UI.
val streamInfo: StreamInfo? = null,
/** Cached strawcore result so the Player + Download dialog can use it. */
val info: uniffi.strawcore.StreamInfo? = null,
)
class VideoDetailViewModel : ViewModel() {
@ -51,18 +54,18 @@ class VideoDetailViewModel : ViewModel() {
private var loadedUrl: String? = 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.
// Activity-scoped VM is reused across nav entries; only re-fetch when
// the requested URL actually changed.
if (loadedUrl == streamUrl && _ui.value.detail != null) return
loadedUrl = streamUrl
_ui.value = VideoDetailUiState(loading = true)
viewModelScope.launch {
try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
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 title = info.title.ifBlank { "(no title)" }
val uploader = info.uploader
val thumb = info.thumbnail
runCatching {
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) {
runCatching { RydClient.fetch(videoId) }.getOrNull()
}
@ -84,19 +89,18 @@ class VideoDetailViewModel : ViewModel() {
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()
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,
)
}
_ui.value = VideoDetailUiState(
loading = false,
@ -106,13 +110,13 @@ class VideoDetailViewModel : ViewModel() {
uploader = uploader,
uploaderUrl = info.uploaderUrl,
viewCount = info.viewCount,
description = info.description?.content ?: "",
description = info.description,
thumbnail = thumb,
ryd = ryd,
sbSegmentCount = sbCount,
related = related,
),
streamInfo = info,
info = info,
)
} catch (t: Throwable) {
_ui.value = VideoDetailUiState(

View file

@ -1,13 +1,16 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* 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
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
@ -17,7 +20,6 @@ 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,
@ -48,8 +50,9 @@ class PlayerViewModel : ViewModel() {
_ui.value = PlayerUiState(loading = true)
viewModelScope.launch {
try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
val info = uniffi.strawcore.streamInfo(streamUrl)
val videoId = info.id
val sbCategories = Settings.get().sbCategories.value.map { it.key }
val segments = if (sbCategories.isEmpty()) {
emptyList()
@ -61,32 +64,24 @@ class PlayerViewModel : ViewModel() {
}
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
// Audit HIGH-8 carry-over: filter by max resolution but fall
// back to lowest available if the ceiling excludes everything.
fun pickVideo(streams: List<uniffi.strawcore.VideoStreamItem>): String? {
if (streams.isEmpty()) return null
val filtered = streams.filter { it.height <= maxRes }
val pool = filtered.ifEmpty { streams }
return pool.maxByOrNull { it.bitrate }?.url
}
val combined = pickVideo(info.videoStreams)
val videoOnly = pickVideo(info.videoOnlyStreams)
val audioOnly = info.audioStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
val combined = pickVideo(info.combined)
val videoOnly = pickVideo(info.videoOnly)
val audioOnly = info.audioOnly.maxByOrNull { it.bitrate }?.url
_ui.value = PlayerUiState(
loading = false,
resolved = ResolvedPlayback(
title = info.name ?: "",
title = info.title,
videoUrl = videoOnly,
audioUrl = audioOnly,
combinedUrl = combined,