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
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue