Path C-4: PlayerViewModel + VideoDetailViewModel swap to uniffi.strawcore.streamInfo

Drops NewPipeExtractor's StreamInfo.getInfo() from the player resolve
path and the video-detail load path. strawcore.streamInfo() is a
single Rust round-trip backed by rustypipe via UniFFI; returns the
adaptive video / video-only / audio-only lists, DASH MPD + HLS URLs,
description, view/like counts, thumbnail, and related-video list.

VideoDetailUiState.streamInfo flips from org.schabi.newpipe.extractor.stream.StreamInfo
to uniffi.strawcore.StreamInfo — used by the Download dialog in
VideoDetailScreen. Dialog field accesses updated accordingly.

moreFromChannel still uses NewPipeExtractor's ChannelInfo until C-5
swaps it to strawcore.channelInfo(). Keeps blast radius surgical.
This commit is contained in:
Kayos 2026-05-24 13:13:04 -07:00
parent 7968bbb8e6
commit 47e037ee62
3 changed files with 51 additions and 58 deletions

View file

@ -284,10 +284,8 @@ 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
// info is now uniffi.strawcore.StreamInfo (Path C-4).
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)"
@ -298,14 +296,8 @@ 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,10 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase U-3 / Path C-4: streamInfo() moves from NewPipeExtractor (Java) to
* strawcore (Rust + rustypipe via UniFFI). Channel fetch for
* `moreFromChannel` stays on NPE until C-5.
*/
package com.sulkta.straw.feature.detail
@ -25,7 +29,6 @@ import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class VideoDetail(
@ -48,8 +51,8 @@ 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,
// Stored on success for handoff to the player + Download dialog. Not in UI.
val streamInfo: uniffi.strawcore.StreamInfo? = null,
)
class VideoDetailViewModel : ViewModel() {
@ -66,11 +69,12 @@ class VideoDetailViewModel : ViewModel() {
_ui.value = VideoDetailUiState(loading = true)
viewModelScope.launch {
try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
// strawcore.streamInfo is suspend on tokio; no Dispatchers.IO wrap.
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 thumb = info.thumbnail
val title = info.title.ifBlank { "(no title)" }
val uploader = info.uploader
runCatching {
History.get().recordWatch(
@ -92,23 +96,23 @@ 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()
// More from this channel — anchored to the uploader the user
// already chose. Best-effort: empty if the fetch fails so the
// detail screen still renders. Filters out the current video.
// strawcore returns `related` as List<SearchItem>. Map to the
// Kotlin StreamItem shape used elsewhere.
val related = info.related.map { r ->
com.sulkta.straw.feature.search.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,
)
}
// More from this channel — still on NewPipeExtractor for now.
// C-5 will swap this to strawcore.channelInfo(url).
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> =
if (info.uploaderUrl.isNullOrBlank()) emptyList()
else withContext(Dispatchers.IO) {
@ -146,7 +150,7 @@ class VideoDetailViewModel : ViewModel() {
uploader = uploader,
uploaderUrl = info.uploaderUrl,
viewCount = info.viewCount,
description = info.description?.content ?: "",
description = info.description,
thumbnail = thumb,
ryd = ryd,
sbSegmentCount = sbCount,

View file

@ -1,13 +1,16 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase U-3 / Path C-4: 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,11 @@ class PlayerViewModel : ViewModel() {
_ui.value = PlayerUiState(loading = true)
viewModelScope.launch {
try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
// strawcore.streamInfo is a suspend fun running on the tokio
// runtime baked into libstrawcore.so — no Dispatchers.IO needed.
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 +66,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,