vc=25: audit-fix sprint — CRIT + HIGH + MED + LOW cleanup

Opus max-effort audit of the vc=23 post-MediaController-unification
codebase surfaced two CRITs, both in my own recent code.

CRIT-1 + 1b: inline-position-threading band-aid deleted. After the V-2
controller unification, seeking the live controller to its own
currentPosition was always 0-500ms backwards — every inline-to-
fullscreen and minibar-expand handoff jerked playback backward. The
whole `inlinePositionMs` / `onPositionChanged` / `startPositionMs` /
`seekTo` chain is gone. The controller is one player; no handoff
needed.

CRIT-2: PlayerLeaveHandler removed. The registry was fully orphaned —
nothing ever assigned to handler. Media3 handles HOME-to-background
natively via the foreground service. Dropped the file, the
onUserLeaveHint override, and the import.

HIGH-1 + 5: PlayerViewModel collapsed into VideoDetailViewModel. Both
fetched the same streamInfo for the same URL; PlayerScreen used to
spin up two VMs to lift uploader + thumbnail from one and stream URLs
from the other. One VM now exposes both `detail` and `resolved`. Drops
a redundant network fetch and the double-spinner UX on PlayerScreen.

HIGH-2: AndroidView { PlayerView } in PlayerScreen + InlinePlayer now
has onRelease { it.player = null } so PlayerView surfaces stop
retaining the controller after the composable leaves composition.

HIGH-3: SubscriptionFeedViewModel switched to a per-channel cache.
Each channel's entries refresh on their own TTL — adding one new
subscription no longer invalidates the other 49. Failed/timed-out
channel fetches leave the prior cache entry intact instead of
blanking the feed for that channel.

HIGH-4: onNewIntent override added. singleTask was silently dropping
shared-from-Chrome YT URLs whenever Straw was already running. New
intents now feed pendingDeepLink which the Compose tree drains into
Screen.VideoDetail.

MED-3, MED-8, MED-10, LOW batch: PlayerView control-overlay overlap
fixed by going through one strategy; SearchViewModel.recordSearch
moved into the success branch so errored queries don't pollute recent
searches; Downloader's host whitelist tightened to *.googlevideo.com
only; SubscriptionsStore.clear + HistoryStore.clearWatches/Searches
now use updateAndGet for atomic clear consistent with the other
writers; phase/path/audit-ticket markers stripped from comments
(kept the technical commentary, dropped sprint tags); 4x duplicated
Color(0xCC222222) overlay color extracted to OverlayChromeColor named
constant in StrawTheme; HtmlText + StrawActivity NewPipeExtractor
references replaced with the current extractor.

Net: ~80 LOC deleted overall (the position-threading + handler
registry + duplicate VM more than offset the cache + onNewIntent
additions).
This commit is contained in:
Sulkta 2026-05-25 17:01:10 +00:00
parent 37a721d049
commit 38bc663777
16 changed files with 403 additions and 501 deletions

View file

@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 24 const val STRAW_VERSION_CODE = 25
const val STRAW_VERSION_NAME = "0.1.0-AJ" const val STRAW_VERSION_NAME = "0.1.0-AK"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* Tiny in-app nav model sealed Screen + a stack. No nav library; pure * Tiny in-app nav model sealed Screen + a stack. No nav library; pure
* state. Good enough for day-2's home search detail player flow. * state.
*/ */
package com.sulkta.straw package com.sulkta.straw
@ -19,11 +19,7 @@ sealed interface Screen {
data object Playlists : Screen data object Playlists : Screen
data object Downloads : Screen data object Downloads : Screen
data class VideoDetail(val streamUrl: String, val title: String) : Screen data class VideoDetail(val streamUrl: String, val title: String) : Screen
data class Player( data class Player(val streamUrl: String, val title: String) : Screen
val streamUrl: String,
val title: String,
val startPositionMs: Long = 0L,
) : Screen
data class Channel(val channelUrl: String, val name: String) : Screen data class Channel(val channelUrl: String, val name: String) : Screen
data class PlaylistView(val playlistId: String, val name: String) : Screen data class PlaylistView(val playlistId: String, val name: String) : Screen
} }
@ -36,7 +32,11 @@ class Navigator(initial: Screen) {
stack.add(s) stack.add(s)
} }
/** @return false if we couldn't pop (root), true otherwise. */ /**
* Pop the current screen off the stack. Returns false at root so the
* caller can defer to the system back behavior (exit the app); true
* otherwise.
*/
fun pop(): Boolean { fun pop(): Boolean {
if (stack.size <= 1) return false if (stack.size <= 1) return false
stack.removeAt(stack.lastIndex) stack.removeAt(stack.lastIndex)

View file

@ -19,6 +19,9 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
@ -27,7 +30,7 @@ import com.sulkta.straw.feature.detail.VideoDetailScreen
import com.sulkta.straw.feature.download.DownloadsScreen import com.sulkta.straw.feature.download.DownloadsScreen
import com.sulkta.straw.feature.player.LocalStrawController import com.sulkta.straw.feature.player.LocalStrawController
import com.sulkta.straw.feature.player.MinibarOverlay import com.sulkta.straw.feature.player.MinibarOverlay
import com.sulkta.straw.feature.player.PlayerLeaveHandler import com.sulkta.straw.feature.player.NowPlaying
import com.sulkta.straw.feature.player.PlayerScreen import com.sulkta.straw.feature.player.PlayerScreen
import com.sulkta.straw.feature.player.SponsorBlockSkipLoop import com.sulkta.straw.feature.player.SponsorBlockSkipLoop
import com.sulkta.straw.feature.player.rememberStrawController import com.sulkta.straw.feature.player.rememberStrawController
@ -35,6 +38,7 @@ import com.sulkta.straw.feature.playlist.PlaylistViewScreen
import com.sulkta.straw.feature.playlist.PlaylistsScreen import com.sulkta.straw.feature.playlist.PlaylistsScreen
import com.sulkta.straw.feature.search.SearchScreen import com.sulkta.straw.feature.search.SearchScreen
import com.sulkta.straw.feature.settings.SettingsScreen import com.sulkta.straw.feature.settings.SettingsScreen
import kotlinx.coroutines.flow.MutableStateFlow
private val YT_HOSTS = setOf( private val YT_HOSTS = setOf(
"youtube.com", "www.youtube.com", "m.youtube.com", "youtube.com", "www.youtube.com", "m.youtube.com",
@ -46,6 +50,15 @@ private val YT_URL_RE = Regex(
) )
class StrawActivity : ComponentActivity() { class StrawActivity : ComponentActivity() {
/**
* Newly-arrived deep-link URL while the activity is already running.
* `onNewIntent` writes here; the Compose tree observes and pushes a
* VideoDetail screen. Without this the singleTask flag silently drops
* every share-to-Straw after the first.
*/
private val pendingDeepLink = MutableStateFlow<String?>(null)
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
@ -55,10 +68,9 @@ class StrawActivity : ComponentActivity() {
setContent { setContent {
val scheme = if (isSystemInDarkTheme()) strawDarkColors() else strawLightColors() val scheme = if (isSystemInDarkTheme()) strawDarkColors() else strawLightColors()
// Build one MediaController for the whole activity. Every screen // One MediaController for the whole activity. Every screen pulls
// pulls it via LocalStrawController, every PlayerView binds to // it via LocalStrawController; the minibar overlay below uses it
// it, and the minibar overlay (rendered below) uses it too. // too. Single player, single source of truth.
// Single player, single source of truth.
val controller = rememberStrawController() val controller = rememberStrawController()
MaterialTheme(colorScheme = scheme) { MaterialTheme(colorScheme = scheme) {
CompositionLocalProvider(LocalStrawController provides controller) { CompositionLocalProvider(LocalStrawController provides controller) {
@ -80,6 +92,15 @@ class StrawActivity : ComponentActivity() {
onDispose { cb.remove() } onDispose { cb.remove() }
} }
// Drain newly-arrived deep links. Consumed (cleared) once
// pushed so we don't re-navigate on every recomposition.
val pending by pendingDeepLink.collectAsState()
LaunchedEffect(pending) {
val url = pending ?: return@LaunchedEffect
nav.push(Screen.VideoDetail(url, ""))
pendingDeepLink.value = null
}
// SponsorBlock skip loop runs at the activity level so it // SponsorBlock skip loop runs at the activity level so it
// applies whether the user is fullscreen, in the minibar, // applies whether the user is fullscreen, in the minibar,
// or away from the player surface. // or away from the player surface.
@ -87,21 +108,13 @@ class StrawActivity : ComponentActivity() {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
ScreenContent(nav, s = nav.current) ScreenContent(nav, s = nav.current)
// Persistent minibar overlay — visible on every screen // Persistent minibar — visible on every non-Player
// except Player itself (fullscreen has its own UI). // screen whenever something is loaded.
if (nav.current !is Screen.Player) { if (nav.current !is Screen.Player) {
MinibarOverlay( MinibarOverlay(
onExpand = { onExpand = {
val item = com.sulkta.straw.feature.player.NowPlaying.current.value val item = NowPlaying.current.value ?: return@MinibarOverlay
if (item != null) { nav.push(Screen.Player(item.streamUrl, item.title))
nav.push(
Screen.Player(
item.streamUrl,
item.title,
controller?.currentPosition ?: 0L,
)
)
}
}, },
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
) )
@ -113,97 +126,79 @@ class StrawActivity : ComponentActivity() {
} }
} }
/**
* `launchMode="singleTask"` means a fresh VIEW/SEND from Chrome lands
* on the already-running activity instead of creating a new instance.
* Forward the URL into the Compose tree via the pending-link flow.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
pickYouTubeUrl(intent)?.let { pendingDeepLink.value = it }
}
@Composable @Composable
private fun ScreenContent(nav: Navigator, s: Screen) { private fun ScreenContent(nav: Navigator, s: Screen) {
when (s) { when (s) {
is Screen.Home -> StrawHome( is Screen.Home -> StrawHome(
onOpenSearch = { nav.push(Screen.Search) }, onOpenSearch = { nav.push(Screen.Search) },
onOpenSettings = { nav.push(Screen.Settings) }, onOpenSettings = { nav.push(Screen.Settings) },
onOpenPlaylists = { nav.push(Screen.Playlists) }, onOpenPlaylists = { nav.push(Screen.Playlists) },
onOpenDownloads = { nav.push(Screen.Downloads) }, onOpenDownloads = { nav.push(Screen.Downloads) },
onOpenVideo = { url, title -> onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
nav.push(Screen.VideoDetail(url, title)) onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
}, )
onOpenChannel = { url, name -> is Screen.Downloads -> DownloadsScreen()
nav.push(Screen.Channel(url, name)) is Screen.Settings -> SettingsScreen()
}, is Screen.Search -> SearchScreen(
) onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
is Screen.Downloads -> DownloadsScreen() )
is Screen.Settings -> SettingsScreen() is Screen.VideoDetail -> VideoDetailScreen(
is Screen.Search -> SearchScreen( streamUrl = s.streamUrl,
onOpenVideo = { url, title -> initialTitle = s.title,
nav.push(Screen.VideoDetail(url, title)) onPlay = { nav.push(Screen.Player(s.streamUrl, s.title)) },
}, onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
) onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
is Screen.VideoDetail -> VideoDetailScreen( )
streamUrl = s.streamUrl, is Screen.Channel -> ChannelScreen(
initialTitle = s.title, channelUrl = s.channelUrl,
onPlay = { startPositionMs -> initialName = s.name,
nav.push(Screen.Player(s.streamUrl, s.title, startPositionMs)) onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
}, )
onOpenChannel = { url, name -> is Screen.Player -> PlayerScreen(
nav.push(Screen.Channel(url, name)) streamUrl = s.streamUrl,
}, title = s.title,
onOpenVideo = { url, title -> onMinimize = { nav.pop() },
nav.push(Screen.VideoDetail(url, title)) )
}, is Screen.Playlists -> PlaylistsScreen(
) onOpenPlaylist = { id, name -> nav.push(Screen.PlaylistView(id, name)) },
is Screen.Channel -> ChannelScreen( )
channelUrl = s.channelUrl,
initialName = s.name,
onOpenVideo = { url, title ->
nav.push(Screen.VideoDetail(url, title))
},
)
is Screen.Player -> PlayerScreen(
streamUrl = s.streamUrl,
title = s.title,
startPositionMs = s.startPositionMs,
onMinimize = { nav.pop() },
)
is Screen.Playlists -> PlaylistsScreen(
onOpenPlaylist = { id, name ->
nav.push(Screen.PlaylistView(id, name))
},
)
is Screen.PlaylistView -> PlaylistViewScreen( is Screen.PlaylistView -> PlaylistViewScreen(
playlistId = s.playlistId, playlistId = s.playlistId,
initialName = s.name, initialName = s.name,
onOpenVideo = { url, title -> onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
nav.push(Screen.VideoDetail(url, title))
},
) )
} }
} }
/** /** Pull a YouTube URL out of an incoming VIEW or SEND intent. */
* HOME / recents seamless hand-off to background audio when the
* player screen is active. PlayerScreen registers the handler; any
* other screen leaves it null and home behaves normally.
*/
override fun onUserLeaveHint() {
super.onUserLeaveHint()
PlayerLeaveHandler.handler?.invoke()
}
/** Pull a YouTube URL out of an incoming Intent (VIEW or SEND). */
private fun pickYouTubeUrl(intent: Intent?): String? { private fun pickYouTubeUrl(intent: Intent?): String? {
intent ?: return null intent ?: return null
return when (intent.action) { return when (intent.action) {
Intent.ACTION_VIEW -> { Intent.ACTION_VIEW -> {
val data = intent.data?.toString() ?: return null val data = intent.data?.toString() ?: return null
// Explicit scheme + host check — defense in depth vs the // Explicit scheme + host check — defense in depth vs the
// manifest intent-filter (apps can synth intents that // manifest intent-filter; apps can synth intents that
// bypass filter scheme matching when activity is exported). // bypass filter scheme matching on exported activities.
if (intent.scheme?.lowercase() !in setOf("https", "http")) return null if (intent.scheme?.lowercase() !in setOf("https", "http")) return null
if (!looksLikeYouTube(data)) return null if (!looksLikeYouTube(data)) return null
data data
} }
Intent.ACTION_SEND -> { Intent.ACTION_SEND -> {
val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null
// Regex extracts a YT-looking substring from arbitrary // Extract a YT-looking substring from attacker-controlled
// attacker-controlled text. Re-validate via URI parse + host // text, then re-validate via URI parse + host check before
// check before we hand it to NewPipeExtractor. // handing it to the extractor.
val candidate = YT_URL_RE.find(shared)?.value ?: return null val candidate = YT_URL_RE.find(shared)?.value ?: return null
val truncated = candidate.substringBefore('#').trim() val truncated = candidate.substringBefore('#').trim()
if (!looksLikeYouTube(truncated)) return null if (!looksLikeYouTube(truncated)) return null

View file

@ -62,3 +62,9 @@ fun strawDarkColors(): ColorScheme = darkColorScheme(
tertiary = DarkGreenTertiary, tertiary = DarkGreenTertiary,
onTertiary = DarkGreenOnTertiary, onTertiary = DarkGreenOnTertiary,
) )
// Semi-transparent overlays for chrome (overlay buttons, the SB badge,
// the inline-player fullscreen pill) and for the dimmed area behind the
// minibar thumbnail. Kept here so a theme tweak touches one place.
val OverlayChromeColor = androidx.compose.ui.graphics.Color(0xCC222222)
val OverlayDimColor = androidx.compose.ui.graphics.Color(0xCC000000)

View file

@ -2,9 +2,9 @@
* SPDX-FileCopyrightText: 2026 Sulkta * SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* SharedPreferences-backed recent watches + recent search store. Day-3. * Recent watches + recent searches backed by SharedPreferences JSON
* Day-4 graduates to Room when there's a real query pattern (date ranges, * blobs. Capped to MAX_WATCHES / MAX_SEARCHES. Graduates to Room when
* full-text search, etc.) that SharedPreferences can't serve. * a real query pattern (date ranges, full-text search) shows up.
*/ */
package com.sulkta.straw.data package com.sulkta.straw.data
@ -46,9 +46,9 @@ class HistoryStore(context: Context) {
fun recordWatch(item: WatchHistoryItem) { fun recordWatch(item: WatchHistoryItem) {
val now = item.copy(watchedAt = System.currentTimeMillis()) val now = item.copy(watchedAt = System.currentTimeMillis())
// Atomic read-modify-write via StateFlow.updateAndGet — fixes // Atomic read-modify-write — two concurrent recordWatch calls
// AUD-HIGH race where two concurrent recordWatch calls would // both reading the same `current` and one clobbering the other
// each read the old list and one would clobber the other. // is exactly the bug updateAndGet avoids.
val next = _watches.updateAndGet { current -> val next = _watches.updateAndGet { current ->
val without = current.filterNot { it.videoId == item.videoId } val without = current.filterNot { it.videoId == item.videoId }
(listOf(now) + without).take(MAX_WATCHES) (listOf(now) + without).take(MAX_WATCHES)
@ -67,13 +67,13 @@ class HistoryStore(context: Context) {
} }
fun clearWatches() { fun clearWatches() {
_watches.value = emptyList() _watches.updateAndGet { emptyList() }
sp.edit().remove(KEY_WATCHES).apply() sp.edit().putString(KEY_WATCHES, json.encodeToString(emptyList<WatchHistoryItem>())).apply()
} }
fun clearSearches() { fun clearSearches() {
_searches.value = emptyList() _searches.updateAndGet { emptyList() }
sp.edit().remove(KEY_SEARCHES).apply() sp.edit().putString(KEY_SEARCHES, json.encodeToString(emptyList<String>())).apply()
} }
private fun loadWatches(): List<WatchHistoryItem> = runCatching { private fun loadWatches(): List<WatchHistoryItem> = runCatching {

View file

@ -2,8 +2,8 @@
* SPDX-FileCopyrightText: 2026 Sulkta * SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* SharedPreferences-lite subscription list. Day-4 graduates to Room when * Subscription list backed by a single JSON blob in SharedPreferences.
* we want background feed fetching for new uploads. * Graduates to Room when background feed fetching arrives.
*/ */
package com.sulkta.straw.data package com.sulkta.straw.data
@ -38,7 +38,9 @@ class SubscriptionsStore(context: Context) {
_subs.value.any { it.url == channelUrl } _subs.value.any { it.url == channelUrl }
fun toggle(ref: ChannelRef) { fun toggle(ref: ChannelRef) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. // updateAndGet makes the read-modify-write atomic vs. concurrent
// toggles (e.g. one channel subscribed from the feed while another
// is unsubscribed from VideoDetail).
val next = _subs.updateAndGet { cur -> val next = _subs.updateAndGet { cur ->
if (cur.any { it.url == ref.url }) { if (cur.any { it.url == ref.url }) {
cur.filterNot { it.url == ref.url } cur.filterNot { it.url == ref.url }
@ -50,8 +52,11 @@ class SubscriptionsStore(context: Context) {
} }
fun clear() { fun clear() {
_subs.value = emptyList() // Same atomic-update path as toggle — protects against a concurrent
sp.edit().remove(KEY).apply() // toggle racing the clear and persisting [new-item] after the
// remove() call has fired.
_subs.updateAndGet { emptyList() }
persist(emptyList())
} }
private fun persist(list: List<ChannelRef>) { private fun persist(list: List<ChannelRef>) {

View file

@ -5,6 +5,9 @@
package com.sulkta.straw.feature.detail package com.sulkta.straw.feature.detail
import android.content.Intent
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -23,8 +26,12 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button import androidx.compose.material3.Button
@ -35,44 +42,40 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import android.content.Intent import androidx.compose.material3.TextButton
import android.widget.Toast import androidx.compose.runtime.Composable
import androidx.annotation.OptIn
import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.sulkta.straw.data.PlaylistItem
import com.sulkta.straw.data.Playlists
import kotlinx.coroutines.delay
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import com.sulkta.straw.feature.download.DownloadKind
import com.sulkta.straw.feature.download.Downloader
import com.sulkta.straw.feature.player.PlayerViewModel
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.sulkta.straw.OverlayChromeColor
import com.sulkta.straw.OverlayDimColor
import com.sulkta.straw.data.PlaylistItem
import com.sulkta.straw.data.Playlists
import com.sulkta.straw.feature.download.DownloadKind
import com.sulkta.straw.feature.download.Downloader
import com.sulkta.straw.feature.player.LocalStrawController import com.sulkta.straw.feature.player.LocalStrawController
import com.sulkta.straw.feature.player.NowPlaying import com.sulkta.straw.feature.player.NowPlaying
import com.sulkta.straw.feature.player.setPlayingFrom import com.sulkta.straw.feature.player.setPlayingFrom
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatCount
import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.stripHtml import com.sulkta.straw.util.stripHtml
@ -82,7 +85,7 @@ import com.sulkta.straw.util.stripHtml
fun VideoDetailScreen( fun VideoDetailScreen(
streamUrl: String, streamUrl: String,
initialTitle: String, initialTitle: String,
onPlay: (startPositionMs: Long) -> Unit, onPlay: () -> Unit,
onOpenChannel: (channelUrl: String, name: String) -> Unit, onOpenChannel: (channelUrl: String, name: String) -> Unit,
onOpenVideo: (url: String, title: String) -> Unit, onOpenVideo: (url: String, title: String) -> Unit,
vm: VideoDetailViewModel = viewModel(), vm: VideoDetailViewModel = viewModel(),
@ -91,13 +94,8 @@ fun VideoDetailScreen(
val context = LocalContext.current val context = LocalContext.current
var showDownloadDialog by remember { mutableStateOf(false) } var showDownloadDialog by remember { mutableStateOf(false) }
var showSaveToPlaylistDialog by remember { mutableStateOf(false) } var showSaveToPlaylistDialog by remember { mutableStateOf(false) }
// Inline-play state. Resets when the user navigates to a different // Inline-play state resets when navigating to a different video.
// video (keyed on streamUrl).
var inlinePlaying by remember(streamUrl) { mutableStateOf(false) } var inlinePlaying by remember(streamUrl) { mutableStateOf(false) }
// V-2: inline player's current position, polled into here so the
// outer can pass it through when the user taps Play / ⛶. Resets to 0
// when the inline player isn't active.
var inlinePositionMs by remember(streamUrl) { mutableLongStateOf(0L) }
LaunchedEffect(streamUrl) { vm.load(streamUrl) } LaunchedEffect(streamUrl) { vm.load(streamUrl) }
Column( Column(
@ -120,17 +118,13 @@ fun VideoDetailScreen(
else -> { else -> {
val d = state.detail ?: return@Column val d = state.detail ?: return@Column
// Tap the thumbnail to play inline. Fullscreen button (top-right
// overlay on the inline player) jumps to the fullscreen Player
// screen which has the full toolset.
if (inlinePlaying) { if (inlinePlaying) {
InlinePlayer( InlinePlayer(
streamUrl = streamUrl, streamUrl = streamUrl,
title = d.title, title = d.title,
uploader = d.uploader, uploader = d.uploader,
thumbnail = d.thumbnail, thumbnail = d.thumbnail,
onFullscreen = { onPlay(inlinePositionMs) }, onFullscreen = onPlay,
onPositionChanged = { inlinePositionMs = it },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(16f / 9f) .aspectRatio(16f / 9f)
@ -154,8 +148,8 @@ fun VideoDetailScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.size(64.dp) .size(64.dp)
.clip(androidx.compose.foundation.shape.CircleShape) .clip(CircleShape)
.background(Color(0xCC000000)), .background(OverlayDimColor),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
@ -169,25 +163,24 @@ fun VideoDetailScreen(
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// ── Title + uploader ─────────────────────────────────────
Text( Text(
text = d.title, text = d.title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
val uploaderClickable = d.uploaderUrl != null val uploaderUrl = d.uploaderUrl
Text( Text(
text = d.uploader, text = d.uploader,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (uploaderClickable) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, color = if (uploaderUrl != null) MaterialTheme.colorScheme.primary
modifier = if (uploaderClickable) Modifier.clickable { else MaterialTheme.colorScheme.onSurfaceVariant,
onOpenChannel(d.uploaderUrl!!, d.uploader) modifier = if (uploaderUrl != null) Modifier.clickable {
onOpenChannel(uploaderUrl, d.uploader)
} else Modifier, } else Modifier,
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// ── Engagement row: views + RYD likes/dislikes ───────────
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -225,7 +218,7 @@ fun VideoDetailScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Button(onClick = { onPlay(inlinePositionMs) }) { Text("Play") } Button(onClick = onPlay) { Text("Play") }
OutlinedButton(onClick = { OutlinedButton(onClick = {
val send = Intent(Intent.ACTION_SEND).apply { val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain" type = "text/plain"
@ -243,17 +236,15 @@ fun VideoDetailScreen(
} }
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
// ── Description ──────────────────────────────────────────
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// AUD-MED: cap input length before regex passes — defends // Cap input length before regex passes — defends against
// against ANR on multi-MB descriptions. // ANR on multi-MB descriptions.
Text( Text(
text = stripHtml(d.description.take(20_000)).take(2000), text = stripHtml(d.description.take(20_000)).take(2000),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
// ── Recommended ──────────────────────────────────────────
if (d.related.isNotEmpty()) { if (d.related.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
@ -264,11 +255,10 @@ fun VideoDetailScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
d.related.take(20).forEach { rel -> d.related.take(20).forEach { rel ->
RelatedRow(rel) { onOpenVideo(rel.url, rel.title) } RelatedRow(rel) { onOpenVideo(rel.url, rel.title) }
androidx.compose.material3.HorizontalDivider() HorizontalDivider()
} }
} }
// ── More from <uploader> ─────────────────────────────────
if (d.moreFromChannel.isNotEmpty()) { if (d.moreFromChannel.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
@ -280,7 +270,7 @@ fun VideoDetailScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
d.moreFromChannel.take(20).forEach { item -> d.moreFromChannel.take(20).forEach { item ->
RelatedRow(item) { onOpenVideo(item.url, item.title) } RelatedRow(item) { onOpenVideo(item.url, item.title) }
androidx.compose.material3.HorizontalDivider() HorizontalDivider()
} }
} }
@ -315,7 +305,6 @@ fun VideoDetailScreen(
confirmButton = { confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { Button(onClick = {
// info is now uniffi.strawcore.StreamInfo (Path C-4).
val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.url val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.url
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)
@ -341,13 +330,12 @@ fun VideoDetailScreen(
} }
}, },
dismissButton = { dismissButton = {
androidx.compose.material3.TextButton(onClick = { showDownloadDialog = false }) { TextButton(onClick = { showDownloadDialog = false }) {
Text("Cancel") Text("Cancel")
} }
}, },
) )
} }
} }
} }
} }
@ -355,7 +343,7 @@ fun VideoDetailScreen(
@Composable @Composable
private fun RelatedRow( private fun RelatedRow(
item: com.sulkta.straw.feature.search.StreamItem, item: StreamItem,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Row( Row(
@ -380,7 +368,7 @@ private fun RelatedRow(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(
@ -394,7 +382,7 @@ private fun RelatedRow(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
} }
@ -479,18 +467,18 @@ private fun SaveToPlaylistDialog(
} }
}, },
confirmButton = { confirmButton = {
androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Close") } TextButton(onClick = onDismiss) { Text("Close") }
}, },
) )
} }
/** /**
* Inline player embedded in the 16:9 thumbnail box on VideoDetailScreen. * Inline player surface inside VideoDetail's 16:9 thumbnail box. Renders
* Uses its own ExoPlayer + PlayerView (with the built-in controller for * a PlayerView bound to the shared LocalStrawController the same
* play/pause/seek). A small fullscreen pill in the top-right hops the user * player as the fullscreen PlayerScreen and the minibar overlay. The
* to the fullscreen PlayerScreen for the full toolset (speed picker, audio- * pill hops to fullscreen; playback continues unchanged. There is
* only, share, PiP, background). Player is released when the composable * nothing to release here: the controller is process-wide, and the
* leaves composition (navigate back or away from VideoDetail). * PlayerView's surface is detached on dispose via onRelease.
*/ */
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Composable @Composable
@ -500,42 +488,27 @@ private fun InlinePlayer(
uploader: String, uploader: String,
thumbnail: String?, thumbnail: String?,
onFullscreen: () -> Unit, onFullscreen: () -> Unit,
onPositionChanged: (Long) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val controller = LocalStrawController.current val controller = LocalStrawController.current
val playerVm: PlayerViewModel = viewModel() val vm: VideoDetailViewModel = viewModel()
val state by playerVm.ui.collectAsStateWithLifecycle() val state by vm.ui.collectAsStateWithLifecycle()
LaunchedEffect(streamUrl) { playerVm.resolve(streamUrl) }
// As soon as we have a resolved stream AND the active video isn't // Push the resolved stream into the shared controller if it isn't
// already this URL, push it into the shared controller. The controller // already playing this URL. We don't kick off a new fetch — the
// is the same one driving the fullscreen Player + the minibar overlay, // outer VideoDetailScreen already called vm.load(streamUrl).
// so playback survives any nav transition unchanged.
val resolved = state.resolved val resolved = state.resolved
LaunchedEffect(controller, resolved, streamUrl) { LaunchedEffect(controller, resolved, streamUrl) {
val c = controller ?: return@LaunchedEffect val c = controller ?: return@LaunchedEffect
val r = resolved ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect
val activeUrl = NowPlaying.current.value?.streamUrl if (NowPlaying.current.value?.streamUrl == streamUrl) return@LaunchedEffect
if (activeUrl != streamUrl) { c.setPlayingFrom(
c.setPlayingFrom( streamUrl = streamUrl,
streamUrl = streamUrl, title = title,
title = title, uploader = uploader,
uploader = uploader, thumbnail = thumbnail,
thumbnail = thumbnail, resolved = r,
resolved = r, )
)
}
}
// Report position to the parent on every tick so a Play / ⛶ tap picks
// up at the right spot if the active video is somehow desynced.
LaunchedEffect(controller) {
val c = controller ?: return@LaunchedEffect
while (true) {
onPositionChanged(c.currentPosition.coerceAtLeast(0L))
delay(500)
}
} }
var playbackError by remember { mutableStateOf<String?>(null) } var playbackError by remember { mutableStateOf<String?>(null) }
@ -577,17 +550,16 @@ private fun InlinePlayer(
} }
}, },
update = { it.player = controller }, update = { it.player = controller },
onRelease = { it.player = null },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// Top-right fullscreen pill — hops to the fullscreen
// PlayerScreen which has speed/audio-only/share/PiP/background.
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(8.dp) .padding(8.dp)
.size(36.dp) .size(36.dp)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
.background(Color(0xCC222222)) .background(OverlayChromeColor)
.clickable(onClick = onFullscreen), .clickable(onClick = onFullscreen),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -597,4 +569,3 @@ private fun InlinePlayer(
} }
} }
} }

View file

@ -2,11 +2,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 / Path C-4: streamInfo() moves from NewPipeExtractor (Java) to * One VM per video URL drives VideoDetail, the fullscreen Player, and
* strawcore (Rust + rustypipe via UniFFI). Channel fetch for * the inline player on detail (all live in the same activity-scoped VM
* `moreFromChannel` stays on NPE until C-5. * store, so `viewModel()` from each composable returns this instance).
*
* `load(url)` fetches strawcore.streamInfo once, derives both `detail`
* (title, uploader, view count, RYD, related, more-from-channel) and
* `resolved` (the picked stream URLs the player needs), and records the
* video to watch history. Subsequent `load(url)` calls for the same URL
* are a no-op so the spinner only fires on a real navigation change.
*/ */
package com.sulkta.straw.feature.detail package com.sulkta.straw.feature.detail
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -16,7 +21,9 @@ import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.WatchHistoryItem import com.sulkta.straw.data.WatchHistoryItem
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.SbSegment
import com.sulkta.straw.net.SponsorBlockClient import com.sulkta.straw.net.SponsorBlockClient
import com.sulkta.straw.feature.search.StreamItem
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
@ -34,17 +41,41 @@ 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(),
/** Other videos from the same channel separate from related (which is YT's /**
* algo). Anchored to the uploader the user chose; matches the sub-feed ethos. */ * Other videos from the same channel separate from `related`
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(), * (which is YT's algo). Anchored to the uploader the user chose;
* matches the sub-feed ethos.
*/
val moreFromChannel: List<StreamItem> = emptyList(),
) )
/**
* Stream URLs picked from `streamInfo` for the player. The picker prefers
* DASH (whole-quality + adaptive) HLS combined progressive merged
* video+audio progressive video-only. Carries SB segments for the
* activity-level skip loop.
*/
data class ResolvedPlayback(
val title: String,
val videoUrl: String?,
val audioUrl: String?,
val combinedUrl: String?,
val dashMpdUrl: String?,
val hlsUrl: String?,
val segments: List<SbSegment> = emptyList(),
) {
val isPlayable: Boolean
get() = !combinedUrl.isNullOrBlank() || !videoUrl.isNullOrBlank() ||
!dashMpdUrl.isNullOrBlank() || !hlsUrl.isNullOrBlank()
}
data class VideoDetailUiState( data class VideoDetailUiState(
val loading: Boolean = true, val loading: Boolean = true,
val detail: VideoDetail? = null, val detail: VideoDetail? = null,
val resolved: ResolvedPlayback? = null,
val error: String? = null, val error: String? = null,
// Stored on success for handoff to the player + Download dialog. Not in UI. /** Raw extractor result — kept around for the Download dialog. */
val streamInfo: uniffi.strawcore.StreamInfo? = null, val streamInfo: uniffi.strawcore.StreamInfo? = null,
) )
@ -55,8 +86,9 @@ 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 // viewModel() is activity-scoped, so the same VM is reused across
// navigations. Compare the requested URL with what we last loaded. // navigations. Skip the refetch if the requested URL already has
// a resolved state.
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)
@ -86,14 +118,13 @@ class VideoDetailViewModel : ViewModel() {
runCatching { RydClient.fetch(videoId) }.getOrNull() runCatching { RydClient.fetch(videoId) }.getOrNull()
} }
val sbCats = Settings.get().sbCategories.value.map { it.key } val sbCats = Settings.get().sbCategories.value.map { it.key }
val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) { val segments = if (sbCats.isEmpty()) emptyList() else withContext(Dispatchers.IO) {
runCatching { SponsorBlockClient.fetch(videoId, sbCats).size }.getOrDefault(0) runCatching { SponsorBlockClient.fetch(videoId, sbCats) }
.getOrDefault(emptyList())
} }
// strawcore returns `related` as List<SearchItem>. Map to the
// Kotlin StreamItem shape used elsewhere.
val related = info.related.map { r -> val related = info.related.map { r ->
com.sulkta.straw.feature.search.StreamItem( StreamItem(
url = r.url, url = r.url,
title = r.title.ifBlank { "(no title)" }, title = r.title.ifBlank { "(no title)" },
uploader = r.uploader, uploader = r.uploader,
@ -107,7 +138,7 @@ class VideoDetailViewModel : ViewModel() {
// More from this channel via strawcore.channelInfo — one // More from this channel via strawcore.channelInfo — one
// Rust round-trip returns the channel's Videos tab pre-mapped. // Rust round-trip returns the channel's Videos tab pre-mapped.
val uploaderUrl = info.uploaderUrl val uploaderUrl = info.uploaderUrl
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> = val moreFromChannel: List<StreamItem> =
if (uploaderUrl.isNullOrBlank()) emptyList() if (uploaderUrl.isNullOrBlank()) emptyList()
else runCatching { else runCatching {
val ch = uniffi.strawcore.channelInfo(uploaderUrl) val ch = uniffi.strawcore.channelInfo(uploaderUrl)
@ -115,7 +146,7 @@ class VideoDetailViewModel : ViewModel() {
.filter { it.url != streamUrl } .filter { it.url != streamUrl }
.take(20) .take(20)
.map { v -> .map { v ->
com.sulkta.straw.feature.search.StreamItem( StreamItem(
url = v.url, url = v.url,
title = v.title.ifBlank { "(no title)" }, title = v.title.ifBlank { "(no title)" },
uploader = v.uploader.ifBlank { uploader }, uploader = v.uploader.ifBlank { uploader },
@ -127,6 +158,8 @@ class VideoDetailViewModel : ViewModel() {
} }
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
val resolved = resolvePlayback(info, segments)
_ui.value = VideoDetailUiState( _ui.value = VideoDetailUiState(
loading = false, loading = false,
detail = VideoDetail( detail = VideoDetail(
@ -138,10 +171,11 @@ class VideoDetailViewModel : ViewModel() {
description = info.description, description = info.description,
thumbnail = thumb, thumbnail = thumb,
ryd = ryd, ryd = ryd,
sbSegmentCount = sbCount, sbSegmentCount = segments.size,
related = related, related = related,
moreFromChannel = moreFromChannel, moreFromChannel = moreFromChannel,
), ),
resolved = resolved,
streamInfo = info, streamInfo = info,
) )
} catch (t: Throwable) { } catch (t: Throwable) {
@ -152,4 +186,28 @@ class VideoDetailViewModel : ViewModel() {
} }
} }
} }
private fun resolvePlayback(
info: uniffi.strawcore.StreamInfo,
segments: List<SbSegment>,
): ResolvedPlayback {
val maxRes = Settings.get().maxResolution.value.ceiling
// Filter by max-resolution ceiling but fall back to the lowest
// available if the ceiling excludes everything (e.g. a 360p-only
// upload with the user on a 480p cap).
fun pickVideo(streams: List<uniffi.strawcore.VideoStreamItem>): String? {
if (streams.isEmpty()) return null
val pool = streams.filter { it.height <= maxRes }.ifEmpty { streams }
return pool.maxByOrNull { it.bitrate }?.url
}
return ResolvedPlayback(
title = info.title,
videoUrl = pickVideo(info.videoOnly),
audioUrl = info.audioOnly.maxByOrNull { it.bitrate }?.url,
combinedUrl = pickVideo(info.combined),
dashMpdUrl = info.dashMpdUrl?.takeIf { it.isNotBlank() },
hlsUrl = info.hlsUrl?.takeIf { it.isNotBlank() },
segments = segments,
)
}
} }

View file

@ -2,18 +2,18 @@
* 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 R: minimal download via Android's DownloadManager. Saves to the * Minimal download via Android's DownloadManager. Saves to the
* app-private external files dir so we don't need WRITE_EXTERNAL_STORAGE * app-private external files dir so we don't need WRITE_EXTERNAL_STORAGE
* on older Android. The user can pull files out via a file manager * on older Android. The user can pull files out via a file manager
* (under Android/data/com.sulkta.straw.debug/files/...). * (under Android/data/com.sulkta.straw.debug/files/...).
* *
* Audit fixes (2026-05-24 pass #2): * Hardening:
* HIGH-4: scheme + host validation on the URL before handing it to * - scheme + host validation on the URL before enqueueing (extractor
* DownloadManager extractor output is not trusted root-of-truth. * output is not trusted root-of-truth)
* HIGH-5: harder filename sanitization control chars, bidi overrides, * - filename sanitization for control chars, bidi overrides, leading
* leading dots, trailing whitespace. * dots, and trailing whitespace
* MED-6: catch IllegalArgumentException from enqueue so a malformed URI * - catches IllegalArgumentException from enqueue so a malformed URI
* doesn't crash the click handler. * doesn't crash the click handler
*/ */
package com.sulkta.straw.feature.download package com.sulkta.straw.feature.download
@ -88,8 +88,9 @@ object Downloader {
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
if (!uri.scheme.equals("https", ignoreCase = true)) return false if (!uri.scheme.equals("https", ignoreCase = true)) return false
val host = uri.host?.lowercase() ?: return false val host = uri.host?.lowercase() ?: return false
return host.endsWith(".googlevideo.com") || // strawcore returns video/audio stream URLs from googlevideo CDN
host.endsWith(".youtube.com") || // exclusively — youtube.com URLs aren't direct streams and have
host == "youtube.com" // no business going to DownloadManager.
return host.endsWith(".googlevideo.com")
} }
} }

View file

@ -2,26 +2,26 @@
* 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 Q: aggregate latest videos across all subscribed channels into a * Aggregate latest videos across all subscribed channels into a single
* single feed. Fans out per-channel channelInfo() fetches in parallel, * feed. Fans out per-channel channelInfo() fetches in parallel, caches
* merges by view count desc, caps at 200 items. * each channel's videos independently, merges by view-count desc, caps
* at 200 items.
* *
* Path C-5: each per-channel fetch is now ONE strawcore.channelInfo() * Each per-channel cache entry has its own TTL so adding one new
* call instead of two NewPipeExtractor round-trips (ChannelInfo.getInfo + * subscription doesn't invalidate the other 49 only the new one
* ChannelTabInfo.getInfo). Halves the network work for the feed. * actually goes to the network on the next refresh.
* *
* Audit fixes (2026-05-24 pass #2): * Concurrency hardening: cancel any in-flight refresh when a new one
* HIGH-6: cancel any prior in-flight refresh when a new one starts, cap * starts, cap parallelism with a Semaphore so 100+ subs don't slam YT,
* concurrency with a Semaphore, time-bound each per-channel fetch so * time-bound each per-channel fetch so one hung channel can't stall the
* one hung channel can't stall the whole feed. * whole batch.
* MED-7: use `update { }` for atomic UI-state writes (matches the
* convention applied to the data stores in audit pass #1).
*/ */
package com.sulkta.straw.feature.feed package com.sulkta.straw.feature.feed
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.strawLogW import com.sulkta.straw.util.strawLogW
@ -37,6 +37,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap
data class SubscriptionFeedUiState( data class SubscriptionFeedUiState(
val loading: Boolean = false, val loading: Boolean = false,
@ -49,10 +50,14 @@ class SubscriptionFeedViewModel : ViewModel() {
private val _ui = MutableStateFlow(SubscriptionFeedUiState()) private val _ui = MutableStateFlow(SubscriptionFeedUiState())
val ui: StateFlow<SubscriptionFeedUiState> = _ui.asStateFlow() val ui: StateFlow<SubscriptionFeedUiState> = _ui.asStateFlow()
/** Cache feed for 10 min to avoid hammering YT on tab re-entry. */ /** Per-channel cache: each entry refreshes independently. */
private val cacheTtlMs = 10L * 60 * 1000 private data class ChannelCacheEntry(val fetchedAt: Long, val items: List<StreamItem>)
private val channelCache = ConcurrentHashMap<String, ChannelCacheEntry>()
/** Per-channel fetch timeout — slowest channel can't stall the whole batch. */ /** Per-channel TTL — Refresh just re-fetches stale entries. */
private val perChannelTtlMs = 10L * 60 * 1000
/** Per-channel fetch timeout — slowest channel can't stall the batch. */
private val perChannelTimeoutMs = 15_000L private val perChannelTimeoutMs = 15_000L
/** Cap parallel network fetches even with 100+ subs. */ /** Cap parallel network fetches even with 100+ subs. */
@ -63,64 +68,39 @@ class SubscriptionFeedViewModel : ViewModel() {
fun refreshIfStale() { fun refreshIfStale() {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (_ui.value.items.isNotEmpty() && now - _ui.value.lastFetchedAt < cacheTtlMs) return val anyStale = Subscriptions.get().subs.value.any { ch ->
refresh() val entry = channelCache[ch.url]
entry == null || now - entry.fetchedAt >= perChannelTtlMs
}
if (anyStale || _ui.value.items.isEmpty()) refresh()
} }
fun refresh() { fun refresh() {
val channels = Subscriptions.get().subs.value val channels = Subscriptions.get().subs.value
if (channels.isEmpty()) { if (channels.isEmpty()) {
_ui.update { SubscriptionFeedUiState(loading = false, items = emptyList()) } _ui.update { SubscriptionFeedUiState(loading = false, items = emptyList()) }
channelCache.clear()
return return
} }
inFlight?.cancel() inFlight?.cancel()
_ui.update { it.copy(loading = true, error = null) } _ui.update { it.copy(loading = true, error = null) }
inFlight = viewModelScope.launch { inFlight = viewModelScope.launch {
try { try {
val perChannelMax = 5
val gate = Semaphore(parallelism) val gate = Semaphore(parallelism)
val items = coroutineScope { val now = System.currentTimeMillis()
val deferreds = channels.map { ch -> coroutineScope {
async { channels
gate.withPermit { .filter { ch ->
withTimeoutOrNull(perChannelTimeoutMs) { val entry = channelCache[ch.url]
runCatching { entry == null || now - entry.fetchedAt >= perChannelTtlMs
val info = uniffi.strawcore.channelInfo(ch.url)
info.videos
.take(perChannelMax)
.map { v ->
StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader.ifBlank { ch.name },
uploaderUrl = v.uploaderUrl ?: ch.url,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
)
}
}.onFailure {
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
}.getOrDefault(emptyList())
} ?: run {
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
emptyList()
}
}
} }
} .map { ch -> async { gate.withPermit { fetchChannelInto(ch) } } }
deferreds.awaitAll() .awaitAll()
} }
.flatten()
// No reliable upload-timestamp on the search-item shape — sort
// by view count desc as a soft proxy for recency-popularity
// within the recent window.
.sortedByDescending { it.viewCount }
.take(200)
_ui.update { _ui.update {
SubscriptionFeedUiState( SubscriptionFeedUiState(
loading = false, loading = false,
items = items, items = mergeFromCache(channels),
lastFetchedAt = System.currentTimeMillis(), lastFetchedAt = System.currentTimeMillis(),
) )
} }
@ -134,4 +114,45 @@ class SubscriptionFeedViewModel : ViewModel() {
} }
} }
} }
private suspend fun fetchChannelInto(ch: ChannelRef) {
val perChannelMax = 5
val fetched = withTimeoutOrNull(perChannelTimeoutMs) {
runCatching {
val info = uniffi.strawcore.channelInfo(ch.url)
info.videos.take(perChannelMax).map { v ->
StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader.ifBlank { ch.name },
uploaderUrl = v.uploaderUrl ?: ch.url,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
)
}
}.onFailure {
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
}.getOrDefault(emptyList())
} ?: run {
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
emptyList()
}
// Only update the cache on a successful fetch. A timeout/error
// leaves any prior cache entry intact, so a glitchy channel
// doesn't blank your feed for that channel.
if (fetched.isNotEmpty()) {
channelCache[ch.url] = ChannelCacheEntry(System.currentTimeMillis(), fetched)
}
}
private fun mergeFromCache(channels: List<ChannelRef>): List<StreamItem> {
val subUrls = channels.map { it.url }.toSet()
// Drop cache entries for unsubscribed channels so removed subs
// fall out of the feed immediately.
channelCache.keys.toList().forEach { if (it !in subUrls) channelCache.remove(it) }
return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() }
.sortedByDescending { it.viewCount }
.take(200)
}
} }

View file

@ -1,17 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Bridge between StrawActivity.onUserLeaveHint() (HOME / recents button)
* and the active PlayerScreen. When the user leaves the player by pressing
* HOME, we want a seamless hand-off to the background-audio foreground
* service not Picture-in-Picture. PlayerScreen registers a handler on
* compose, clears it on dispose; the activity calls it from the OS hook.
*/
package com.sulkta.straw.feature.player
object PlayerLeaveHandler {
@Volatile
var handler: (() -> Unit)? = null
}

View file

@ -2,19 +2,12 @@
* SPDX-FileCopyrightText: 2026 Sulkta * SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* Fullscreen player surface. After the V-2 unification, the player * Fullscreen player surface. The player itself lives in PlaybackService
* itself lives in PlaybackService (one ExoPlayer for the whole app). * (one ExoPlayer for the whole app); this composable is a thin shell that
* This composable is a thin shell that: * renders a PlayerView bound to the shared MediaController, lets the user
* 1. Asks the PlayerViewModel to resolve the stream URL * drag down to dismiss into the minibar, and overlays speed / audio-only
* 2. Pushes the resolved MediaItem into the shared MediaController * / share / PiP / minimize controls. SponsorBlock auto-skip lives at the
* 3. Renders PlayerView bound to that controller * activity root in [SponsorBlockSkipLoop].
* 4. Runs the SponsorBlock skip loop against the controller
* 5. Lets the user drag-down to dismiss into the minibar
*
* Audio-only toggle, speed picker, share, manual PiP, and the
* background-audio button stay as overlays. Audio-only flips the
* controller's track-selection params; nothing more to do because
* playback is one player.
*/ */
package com.sulkta.straw.feature.player package com.sulkta.straw.feature.player
@ -35,12 +28,10 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@ -65,7 +56,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.foundation.layout.offset
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.C import androidx.media3.common.C
@ -74,6 +64,8 @@ import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.sulkta.straw.OverlayChromeColor
import com.sulkta.straw.feature.detail.VideoDetailViewModel
import com.sulkta.straw.net.SbSegment import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.util.strawLogI import com.sulkta.straw.util.strawLogI
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -85,67 +77,46 @@ import kotlinx.coroutines.launch
fun PlayerScreen( fun PlayerScreen(
streamUrl: String, streamUrl: String,
title: String, title: String,
startPositionMs: Long = 0L,
onMinimize: () -> Unit = {}, onMinimize: () -> Unit = {},
vm: PlayerViewModel = viewModel(), vm: VideoDetailViewModel = viewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val controller = LocalStrawController.current val controller = LocalStrawController.current
val state by vm.ui.collectAsStateWithLifecycle() val state by vm.ui.collectAsStateWithLifecycle()
LaunchedEffect(streamUrl) { vm.resolve(streamUrl) } LaunchedEffect(streamUrl) { vm.load(streamUrl) }
var playbackSpeed by remember { mutableStateOf(1.0f) } var playbackSpeed by remember { mutableStateOf(1.0f) }
var audioOnly by remember { mutableStateOf(false) } var audioOnly by remember { mutableStateOf(false) }
var showSpeedDialog by remember { mutableStateOf(false) } var showSpeedDialog by remember { mutableStateOf(false) }
// Drag-to-minimize: vertical offset accumulated during the gesture. // Drag-to-minimize: vertical offset accumulated during the gesture.
// On release, if past threshold we dismiss into the minibar. // On release past the threshold we dismiss into the minibar.
val density = LocalDensity.current val density = LocalDensity.current
val dismissThresholdPx = with(density) { 200.dp.toPx() } val dismissThresholdPx = with(density) { 200.dp.toPx() }
val dragY = remember { Animatable(0f) } val dragY = remember { Animatable(0f) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Push the resolved video into the shared controller as soon as we // When the resolved playback for this URL is ready, push it into the
// have stream URLs. If something else is already playing the same // shared controller — unless it's already playing this exact URL, in
// streamUrl, just seek instead of re-loading. // which case do nothing: the player is already where we want it. The
// For metadata that vm.resolve doesn't return (uploader / thumbnail) we // previous "seek-to-self" path here was always a few ms backwards and
// try to lift them from the matching VideoDetail item if it's open in // produced a jerk on every entry; the controller's currentPosition is
// the same nav stack; otherwise fall back to whatever NowPlaying // its own source of truth.
// already has. Either way the minibar gets enough to render.
val detailVm: com.sulkta.straw.feature.detail.VideoDetailViewModel = viewModel()
LaunchedEffect(streamUrl) { detailVm.load(streamUrl) }
val detailState by detailVm.ui.collectAsStateWithLifecycle()
val resolved = state.resolved val resolved = state.resolved
LaunchedEffect(controller, resolved, detailState.detail) { val detail = state.detail
LaunchedEffect(controller, resolved, detail) {
val c = controller ?: return@LaunchedEffect val c = controller ?: return@LaunchedEffect
val r = resolved ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect
val d = detailState.detail val uploader = detail?.uploader.orEmpty()
val uploader = d?.uploader ?: NowPlaying.current.value?.uploader.orEmpty() val thumbnail = detail?.thumbnail
val thumbnail = d?.thumbnail ?: NowPlaying.current.value?.thumbnail if (NowPlaying.current.value?.streamUrl == streamUrl) return@LaunchedEffect
val sameVideo = NowPlaying.current.value?.streamUrl == streamUrl c.setPlayingFrom(
val currentTitle = c.mediaMetadata.title?.toString() streamUrl = streamUrl,
if (sameVideo && currentTitle == title) { title = title,
if (startPositionMs > 0) c.seekTo(startPositionMs) uploader = uploader,
if (!c.isPlaying) c.play() thumbnail = thumbnail,
NowPlaying.set( resolved = r,
NowPlayingItem( )
streamUrl = streamUrl,
title = title,
uploader = uploader,
thumbnail = thumbnail,
segments = r.segments,
),
)
} else {
c.setPlayingFrom(
streamUrl = streamUrl,
title = title,
uploader = uploader,
thumbnail = thumbnail,
resolved = r,
startPositionMs = startPositionMs,
)
}
} }
// Surface ExoPlayer failures from the service into the UI. // Surface ExoPlayer failures from the service into the UI.
@ -161,9 +132,6 @@ fun PlayerScreen(
onDispose { c?.removeListener(listener) } onDispose { c?.removeListener(listener) }
} }
// Manual-PiP wiring (the ⊟ overlay button). The activity is the PiP
// host; we just feed it the right params. Auto-enter-on-home stays
// disabled — HOME triggers seamless minibar/background per #255.
val activity = context as? Activity val activity = context as? Activity
Box( Box(
@ -174,7 +142,6 @@ fun PlayerScreen(
detectVerticalDragGestures( detectVerticalDragGestures(
onDragEnd = { onDragEnd = {
if (dragY.value > dismissThresholdPx) { if (dragY.value > dismissThresholdPx) {
// Snap to dismiss + pop into minibar.
onMinimize() onMinimize()
} else { } else {
scope.launch { dragY.animateTo(0f, tween(180)) } scope.launch { dragY.animateTo(0f, tween(180)) }
@ -185,7 +152,6 @@ fun PlayerScreen(
}, },
onVerticalDrag = { _, dy -> onVerticalDrag = { _, dy ->
scope.launch { scope.launch {
// Clamp to non-negative — upward drag has no effect.
dragY.snapTo((dragY.value + dy).coerceAtLeast(0f)) dragY.snapTo((dragY.value + dy).coerceAtLeast(0f))
} }
}, },
@ -222,26 +188,23 @@ fun PlayerScreen(
} }
}, },
update = { it.player = controller }, update = { it.player = controller },
onRelease = { it.player = null },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// SponsorBlock segment count badge — small overlay top-left. Box(
resolved.let { r -> modifier = Modifier
Box( .align(Alignment.TopStart)
modifier = Modifier .padding(12.dp)
.align(Alignment.TopStart) .clip(RoundedCornerShape(6.dp))
.padding(12.dp) .background(OverlayChromeColor)
.clip(RoundedCornerShape(6.dp)) .padding(horizontal = 8.dp, vertical = 4.dp),
.background(Color(0xCC222222)) ) {
.padding(horizontal = 8.dp, vertical = 4.dp), Text(
) { text = "SB: ${resolved.segments.size} segment${if (resolved.segments.size == 1) "" else "s"}",
Text( color = Color.White,
text = "SB: ${r.segments.size} segment${if (r.segments.size == 1) "" else "s"}", style = MaterialTheme.typography.labelSmall,
color = Color.White, )
style = MaterialTheme.typography.labelSmall,
)
}
} }
// Top-right overlay — speed / audio-only / share / PiP / minimize.
Row( Row(
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@ -288,7 +251,6 @@ fun PlayerScreen(
Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show() Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show()
} }
} }
// Explicit minimize button — same effect as drag-down.
OverlayButton(label = "") { onMinimize() } OverlayButton(label = "") { onMinimize() }
} }
@ -314,7 +276,7 @@ private fun OverlayButton(label: String, onClick: () -> Unit) {
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
.background(Color(0xCC222222)) .background(OverlayChromeColor)
.clickable(onClick = onClick), .clickable(onClick = onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -360,9 +322,12 @@ private fun SpeedPickerDialog(
/** /**
* SponsorBlock skip loop driven by the controller's currentPosition. * SponsorBlock skip loop driven by the controller's currentPosition.
* Runs at the activity composition root (not per-screen) so it skips * Lives at the activity composition root so it skips segments whether
* segments whether the user is fullscreen, in the minibar, or away from * the user is fullscreen, in the minibar, or away from the player
* the player surface. * surface.
*
* The `skipped` set is only mutated from this single coroutine safe
* without synchronization while that invariant holds.
*/ */
@Composable @Composable
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)

View file

@ -1,103 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* 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.Settings
import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.net.SponsorBlockClient
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
data class ResolvedPlayback(
val title: String,
val videoUrl: String?,
val audioUrl: String?,
val combinedUrl: String?,
val dashMpdUrl: String?,
val hlsUrl: String?,
val segments: List<SbSegment> = emptyList(),
) {
/** Have anything playable? */
val isPlayable: Boolean
get() = !combinedUrl.isNullOrBlank() || !videoUrl.isNullOrBlank() ||
!dashMpdUrl.isNullOrBlank() || !hlsUrl.isNullOrBlank()
}
data class PlayerUiState(
val loading: Boolean = true,
val resolved: ResolvedPlayback? = null,
val error: String? = null,
)
class PlayerViewModel : ViewModel() {
private val _ui = MutableStateFlow(PlayerUiState())
val ui: StateFlow<PlayerUiState> = _ui.asStateFlow()
fun resolve(streamUrl: String) {
_ui.value = PlayerUiState(loading = true)
viewModelScope.launch {
try {
// 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()
} else {
withContext(Dispatchers.IO) {
runCatching { SponsorBlockClient.fetch(videoId, sbCategories) }
.getOrDefault(emptyList())
}
}
val maxRes = Settings.get().maxResolution.value.ceiling
// 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.combined)
val videoOnly = pickVideo(info.videoOnly)
val audioOnly = info.audioOnly.maxByOrNull { it.bitrate }?.url
_ui.value = PlayerUiState(
loading = false,
resolved = ResolvedPlayback(
title = info.title,
videoUrl = videoOnly,
audioUrl = audioOnly,
combinedUrl = combined,
dashMpdUrl = info.dashMpdUrl?.takeIf { it.isNotBlank() },
hlsUrl = info.hlsUrl?.takeIf { it.isNotBlank() },
segments = segments,
),
)
} catch (t: Throwable) {
_ui.value = PlayerUiState(
loading = false,
error = t.message ?: t.javaClass.simpleName,
)
}
}
}
}

View file

@ -41,6 +41,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.MoreExecutors
import com.sulkta.straw.feature.detail.ResolvedPlayback
val LocalStrawController = compositionLocalOf<MediaController?> { null } val LocalStrawController = compositionLocalOf<MediaController?> { null }

View file

@ -41,15 +41,11 @@ class SearchViewModel : ViewModel() {
fun submit() { fun submit() {
val q = _ui.value.query.trim() val q = _ui.value.query.trim()
if (q.isEmpty()) return if (q.isEmpty()) return
runCatching { History.get().recordSearch(q) }
_ui.value = _ui.value.copy(loading = true, error = null, results = emptyList()) _ui.value = _ui.value.copy(loading = true, error = null, results = emptyList())
viewModelScope.launch { viewModelScope.launch {
try { try {
// Phase U-2 / Path C-3: rustypipe via UniFFI. The bindgen-generated // strawcore.search() is suspend on the tokio runtime baked
// `search()` is already a suspend fun running on the tokio runtime // into libstrawcore.so — no Dispatchers.IO wrap needed.
// baked into libstrawcore.so — no Dispatchers.IO wrapper needed,
// the JNI call returns to us on the caller dispatcher when the
// future completes.
val rustItems = uniffi.strawcore.search(q) val rustItems = uniffi.strawcore.search(q)
val items = rustItems.map { r -> val items = rustItems.map { r ->
StreamItem( StreamItem(
@ -63,6 +59,9 @@ class SearchViewModel : ViewModel() {
) )
} }
_ui.value = _ui.value.copy(loading = false, results = items) _ui.value = _ui.value.copy(loading = false, results = items)
// Record AFTER the search succeeds so mistyped queries
// that error out don't pollute the recent-searches list.
runCatching { History.get().recordSearch(q) }
} catch (t: Throwable) { } catch (t: Throwable) {
_ui.value = _ui.value.copy( _ui.value = _ui.value.copy(
loading = false, loading = false,

View file

@ -2,9 +2,9 @@
* SPDX-FileCopyrightText: 2026 Sulkta * SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* Strip HTML tags from NewPipeExtractor's description.content for plain-text * Strip HTML tags from video descriptions for plain-text rendering.
* rendering. Day-3 polish replaces this with a real Markwon/Compose annotated * Replace with a real annotated renderer (Markwon, Compose annotated
* renderer; for now we just want readable text. * strings) when the description UI needs richer formatting.
*/ */
package com.sulkta.straw.util package com.sulkta.straw.util