From 47e037ee625f3b4dbb75a331a0b82575062a90a0 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sun, 24 May 2026 13:13:04 -0700 Subject: [PATCH] Path C-4: PlayerViewModel + VideoDetailViewModel swap to uniffi.strawcore.streamInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../straw/feature/detail/VideoDetailScreen.kt | 16 ++---- .../feature/detail/VideoDetailViewModel.kt | 52 ++++++++++--------- .../straw/feature/player/PlayerViewModel.kt | 41 +++++++-------- 3 files changed, 51 insertions(+), 58 deletions(-) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 1720dd4da..679be2de1 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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)" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 06ee83b6c..880194d30 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -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() - ?.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. 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 = 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, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt index ee523f634..5dd9eeaa3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt @@ -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?): 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): 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,