vc=48: autoplay (off / same-channel / yt-related) + SB for queued items

Two requested features in one ship.

Autoplay:
  * Settings → Autoplay section. Three modes: Off, Same channel,
    YouTube related. Default Same channel — per Cobb 2026-05-26,
    "plays next account's video".
  * Skip-already-watched toggle, default on. Autoplay picks the
    first un-watched candidate (filters History.watches by videoId).
  * When STATE_ENDED fires and the queue has no next item,
    PlaybackService's autoplay handler picks a candidate per mode,
    resolves it via strawcore, and enqueues — which auto-starts
    because the queue is empty (enqueueLast routes through
    setPlayingFrom in that case).
  * SameChannel calls strawcore.channelInfo(uploaderUrl).take(1).
    Plumbed NowPlayingItem.uploaderUrl + setPlayingFrom/enqueueLast
    sig to carry it forward so the autoplay handler has what it
    needs without re-resolving.
  * YtRelated re-resolves the current streamInfo and picks
    info.related[0]. strawcore returns empty for related today, so
    YtRelated falls open to no-op until that extractor work lands —
    documented in the AutoplayMode enum help text.

SponsorBlock for queued items:
  * The vc=47 known-limitation. Now: when onMediaItemTransition
    surfaces a queued item with empty SB segments, fire a
    background fetch of SB for that video, then NowPlaying.claim()
    again with the freshened segments. The skip-loop (reactive on
    NowPlaying.current.segments) picks them up.
  * Fetch lives in StrawApp.globalScope — outlives the controller
    transition + sheet UI.

Refactor:
  * StrawMediaController extensions retyped from MediaController →
    Player so PlaybackService can call them on the ExoPlayer
    directly. MediaController IS a Player so all existing UI call
    sites continue to work.
  * Shared extractYtVideoId util in feature/detail/StreamResolution.kt.
    The duplicate VIDEO_ID_RE in StrawHome.kt will fold into it next
    time that file is touched.
This commit is contained in:
Kayos 2026-05-26 07:04:35 -07:00
parent 02381edf03
commit 964bcddb3a
9 changed files with 295 additions and 9 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
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 47
const val STRAW_VERSION_NAME = "0.1.0-BG"
const val STRAW_VERSION_CODE = 48
const val STRAW_VERSION_NAME = "0.1.0-BH"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -43,11 +43,28 @@ enum class ThemeMode(val label: String) {
Dark("Dark"),
}
/**
* When a video ends with nothing left in the queue, what should the
* player do? `Off` stops at the end (matches NewPipe's default).
* `SameChannel` chains to the next video from the same uploader
* fits Straw's user-curated ethos (you opted into this channel).
* `YtRelated` pulls from `info.related` (YouTube's algorithmic
* suggestion); deferred until strawcore populates `related` from
* the /next response for now it's identical to `Off`.
*/
enum class AutoplayMode(val label: String, val help: String) {
Off("Off", "Stop at the end."),
SameChannel("Same channel", "Play the next video from the same uploader."),
YtRelated("YouTube related", "Pull from YT's related suggestions. (not yet wired — extractor returns empty)"),
}
private const val PREFS = "straw_settings"
private const val KEY_SB_CATS = "sb_categories_v1"
private const val KEY_MAX_RES = "max_resolution_v1"
private const val KEY_THEME = "theme_mode_v1"
private const val KEY_CACHE_ENABLED = "cache_enabled_v1"
private const val KEY_AUTOPLAY_MODE = "autoplay_mode_v1"
private const val KEY_AUTOPLAY_SKIP_WATCHED = "autoplay_skip_watched_v1"
class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@ -64,6 +81,14 @@ class SettingsStore(context: Context) {
private val _cacheEnabled = MutableStateFlow(sp.getBoolean(KEY_CACHE_ENABLED, true))
val cacheEnabled: StateFlow<Boolean> = _cacheEnabled.asStateFlow()
private val _autoplayMode = MutableStateFlow(loadAutoplayMode())
val autoplayMode: StateFlow<AutoplayMode> = _autoplayMode.asStateFlow()
private val _autoplaySkipWatched = MutableStateFlow(
sp.getBoolean(KEY_AUTOPLAY_SKIP_WATCHED, true),
)
val autoplaySkipWatched: StateFlow<Boolean> = _autoplaySkipWatched.asStateFlow()
fun toggle(cat: SbCategory) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
val next = _sbCategories.updateAndGet { cur ->
@ -98,6 +123,20 @@ class SettingsStore(context: Context) {
sp.edit().putBoolean(KEY_CACHE_ENABLED, enabled).apply()
}
fun setAutoplayMode(mode: AutoplayMode) {
val before = _autoplayMode.value
if (before == mode) return
_autoplayMode.value = mode
sp.edit().putString(KEY_AUTOPLAY_MODE, mode.name).apply()
}
fun setAutoplaySkipWatched(skip: Boolean) {
val before = _autoplaySkipWatched.value
if (before == skip) return
_autoplaySkipWatched.value = skip
sp.edit().putBoolean(KEY_AUTOPLAY_SKIP_WATCHED, skip).apply()
}
private fun loadCategories(): Set<SbCategory> {
val raw = sp.getStringSet(KEY_SB_CATS, null)
return if (raw == null) {
@ -117,6 +156,14 @@ class SettingsStore(context: Context) {
val name = sp.getString(KEY_THEME, null) ?: return ThemeMode.System
return ThemeMode.entries.firstOrNull { it.name == name } ?: ThemeMode.System
}
private fun loadAutoplayMode(): AutoplayMode {
// Default to SameChannel — user explicitly chose "on by default,
// plays next account's video" 2026-05-26. Off-by-default doesn't
// fit the workflow (queue empties → silence).
val name = sp.getString(KEY_AUTOPLAY_MODE, null) ?: return AutoplayMode.SameChannel
return AutoplayMode.entries.firstOrNull { it.name == name } ?: AutoplayMode.SameChannel
}
}
object Settings {

View file

@ -11,6 +11,21 @@ package com.sulkta.straw.feature.detail
import com.sulkta.straw.data.Settings
import com.sulkta.straw.net.SbSegment
/**
* Extract the YouTube video ID from a watch URL. Handles the common
* forms: `youtube.com/watch?v=XXXXXXXXXXX`, `youtu.be/X...`, and
* `youtube.com/shorts/X...`. Returns null when nothing matches.
*
* Centralized here so the autoplay + history + import paths all
* resolve videoIds the same way. Duplicates an earlier per-file regex
* (`StrawHome.kt:VIDEO_ID_RE`) that one can fold into this when next
* touched.
*/
private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&#].*)?$")
fun extractYtVideoId(url: String): String? =
VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1)?.takeIf { it.isNotBlank() }
/**
* Convert a raw strawcore.StreamInfo into the picked-URL DTO the
* MediaController wants. Honors Settings.maxResolution cap-fit if

View file

@ -437,6 +437,7 @@ fun VideoDetailScreen(
uploader = d.uploader,
thumbnail = d.thumbnail,
resolved = r,
uploaderUrl = d.uploaderUrl,
)
}
// Audio-only: drop video track. Foreground
@ -488,6 +489,7 @@ fun VideoDetailScreen(
uploader = d.uploader,
thumbnail = d.thumbnail,
resolved = r,
uploaderUrl = d.uploaderUrl,
)
}
val params = PictureInPictureParams.Builder()
@ -746,6 +748,7 @@ private fun InlinePlayer(
uploader = uploader,
thumbnail = thumbnail,
resolved = r,
uploaderUrl = state.detail?.uploaderUrl,
)
}

View file

@ -24,6 +24,14 @@ data class NowPlayingItem(
val streamUrl: String,
val title: String,
val uploader: String,
/**
* Uploader's channel URL needed by the autoplay path so the
* end-of-video handler can call channelInfo() to find the next
* same-channel candidate. Optional because some items come from
* paths where we don't have it (deep links, history rows on a
* cold start before strawcore has resolved metadata).
*/
val uploaderUrl: String? = null,
val thumbnail: String?,
val segments: List<SbSegment> = emptyList(),
)

View file

@ -45,8 +45,18 @@ import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.sulkta.straw.StrawActivity
import com.sulkta.straw.StrawApp
import com.sulkta.straw.data.AutoplayMode
import com.sulkta.straw.data.History
import com.sulkta.straw.data.Settings
import com.sulkta.straw.feature.detail.resolveStreamPlayback
import com.sulkta.straw.net.IosSafeHttpDataSource
import com.sulkta.straw.net.STRAW_USER_AGENT
import com.sulkta.straw.net.SponsorBlockClient
import com.sulkta.straw.util.runCatchingCancellable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@UnstableApi
class PlaybackService : MediaSessionService() {
@ -105,16 +115,144 @@ class PlaybackService : MediaSessionService() {
// and push it into NowPlaying so the minibar + SponsorBlock
// skip-loop reflect the new track. claim() handles concurrent
// setPlayingFrom races — see vc=35 audit HIGH-C6.
//
// SponsorBlock for queued items: when a queued item's segments
// are empty (which they always are — enqueueNext/Last doesn't
// pre-fetch SB to avoid the network round-trip on every long-
// press), kick off a background fetch and re-claim with the
// freshened segments. NowPlaying.claim handles the
// "same-streamUrl with fresher metadata" case via its CAS.
//
// Autoplay at end-of-queue: when STATE_ENDED fires and there's
// no next item in the queue, consult Settings.autoplayMode and
// pick a candidate. SameChannel → call channelInfo on the
// current uploader, take the first un-watched (gated on
// autoplaySkipWatched). YtRelated → would re-call streamInfo
// and pick info.related[0] but strawcore returns empty for
// related today, so it's a no-op until that lands.
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
if (item == null) return
val idx = player.currentMediaItemIndex
val queued = Queue.at(idx) ?: return
NowPlaying.claim(queued)
if (queued.segments.isEmpty()) {
val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(queued.streamUrl)
if (!videoId.isNullOrBlank()) fetchSbForQueued(queued, videoId)
}
}
override fun onPlaybackStateChanged(state: Int) {
if (state != Player.STATE_ENDED) return
val mode = Settings.get().autoplayMode.value
if (mode == AutoplayMode.Off) return
// Media3 auto-advances inside the queue; we only kick
// in when the queue has truly run out. mediaItemCount
// hits 0 after the engine reports STATE_ENDED in some
// edge cases — handle both.
val atEnd = player.mediaItemCount <= 1 ||
player.currentMediaItemIndex >= player.mediaItemCount - 1
if (!atEnd) return
tryAutoplay(mode)
}
})
}
private fun fetchSbForQueued(item: NowPlayingItem, videoId: String) {
StrawApp.globalScope.launch {
runCatchingCancellable {
val cats = Settings.get().sbCategories.value.map { it.key }
if (cats.isEmpty()) return@runCatchingCancellable
val segments = withContext(Dispatchers.IO) {
SponsorBlockClient.fetch(videoId, cats)
}
if (segments.isNotEmpty()) {
NowPlaying.claim(item.copy(segments = segments))
}
}
}
}
private fun tryAutoplay(mode: AutoplayMode) {
val current = NowPlaying.current.value ?: return
val uploaderUrl = current.uploaderUrl
// We need the channel URL for the SameChannel path; YtRelated
// re-resolves the current video's info. If we don't have what
// we need, silently bail — better than a half-baked surprise.
val controller = (mediaSession?.player as? Player) ?: return
StrawApp.globalScope.launch {
runCatchingCancellable {
val candidateUrl = withContext(Dispatchers.IO) {
pickAutoplayCandidate(mode, current.streamUrl, uploaderUrl)
} ?: return@runCatchingCancellable
// Resolve + enqueue + auto-play. Because the queue is
// currently empty (we just ended), enqueueLast routes
// through setPlayingFrom (auto-starts).
val info = withContext(Dispatchers.IO) {
uniffi.strawcore.streamInfo(candidateUrl)
}
val resolved = resolveStreamPlayback(info)
withContext(Dispatchers.Main) {
controller.enqueueLast(
streamUrl = candidateUrl,
title = info.title,
uploader = info.uploader,
thumbnail = info.thumbnail,
resolved = resolved,
uploaderUrl = info.uploaderUrl,
)
}
}
}
}
private fun pickAutoplayCandidate(
mode: AutoplayMode,
currentStreamUrl: String,
uploaderUrl: String?,
): String? = when (mode) {
AutoplayMode.Off -> null
AutoplayMode.SameChannel -> {
if (uploaderUrl.isNullOrBlank()) null
else runCatching {
val ch = uniffi.strawcore.channelInfo(uploaderUrl)
val watched = if (Settings.get().autoplaySkipWatched.value) {
History.get().watches.value.map { it.videoId }.toSet()
} else emptySet()
ch.videos
.asSequence()
.filter { it.url != currentStreamUrl }
.filter {
if (watched.isEmpty()) true
else {
val id = com.sulkta.straw.feature.detail.extractYtVideoId(it.url)
id == null || id !in watched
}
}
.firstOrNull()?.url
}.getOrNull()
}
AutoplayMode.YtRelated -> {
runCatching {
val info = uniffi.strawcore.streamInfo(currentStreamUrl)
val watched = if (Settings.get().autoplaySkipWatched.value) {
History.get().watches.value.map { it.videoId }.toSet()
} else emptySet()
info.related
.asSequence()
.filter { it.url != currentStreamUrl }
.filter {
if (watched.isEmpty()) true
else {
val id = com.sulkta.straw.feature.detail.extractYtVideoId(it.url)
id == null || id !in watched
}
}
.firstOrNull()?.url
}.getOrNull()
}
}
override fun onGetSession(
controllerInfo: MediaSession.ControllerInfo,
): MediaSession? = mediaSession

View file

@ -117,6 +117,7 @@ fun PlayerScreen(
uploader = uploader,
thumbnail = thumbnail,
resolved = r,
uploaderUrl = detail?.uploaderUrl,
)
}

View file

@ -37,6 +37,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
@ -74,19 +75,21 @@ fun rememberStrawController(): MediaController? {
* MediaSource based on MIME + the EXTRA_AUDIO_URL bundle.
*/
@UnstableApi
fun MediaController.setPlayingFrom(
fun Player.setPlayingFrom(
streamUrl: String,
title: String,
uploader: String,
thumbnail: String?,
resolved: ResolvedPlayback,
startPositionMs: Long = 0L,
uploaderUrl: String? = null,
) {
val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return
val nowPlayingItem = NowPlayingItem(
streamUrl = streamUrl,
title = title,
uploader = uploader,
uploaderUrl = uploaderUrl,
thumbnail = thumbnail,
segments = resolved.segments,
)
@ -116,31 +119,34 @@ fun MediaController.setPlayingFrom(
* failure (no playable stream in `resolved`).
*/
@UnstableApi
fun MediaController.enqueueNext(
fun Player.enqueueNext(
streamUrl: String,
title: String,
uploader: String,
thumbnail: String?,
resolved: ResolvedPlayback,
): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, asNext = true)
uploaderUrl: String? = null,
): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, uploaderUrl, asNext = true)
/** Append to the back of the queue. Same idle-fallback as enqueueNext. */
@UnstableApi
fun MediaController.enqueueLast(
fun Player.enqueueLast(
streamUrl: String,
title: String,
uploader: String,
thumbnail: String?,
resolved: ResolvedPlayback,
): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, asNext = false)
uploaderUrl: String? = null,
): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, uploaderUrl, asNext = false)
@UnstableApi
private fun MediaController.enqueueInternal(
private fun Player.enqueueInternal(
streamUrl: String,
title: String,
uploader: String,
thumbnail: String?,
resolved: ResolvedPlayback,
uploaderUrl: String?,
asNext: Boolean,
): Boolean {
val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return false
@ -148,13 +154,14 @@ private fun MediaController.enqueueInternal(
streamUrl = streamUrl,
title = title,
uploader = uploader,
uploaderUrl = uploaderUrl,
thumbnail = thumbnail,
segments = resolved.segments,
)
// Empty queue — there's nothing to "enqueue" onto. Treat as a
// start-playing-now and route through the normal claim path.
if (mediaItemCount == 0) {
setPlayingFrom(streamUrl, title, uploader, thumbnail, resolved)
setPlayingFrom(streamUrl, title, uploader, thumbnail, resolved, uploaderUrl = uploaderUrl)
return true
}
val insertIndex = if (asNext) currentMediaItemIndex + 1 else mediaItemCount

View file

@ -44,6 +44,7 @@ import android.widget.Toast
import androidx.lifecycle.viewmodel.compose.viewModel
import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.History
import com.sulkta.straw.data.AutoplayMode
import com.sulkta.straw.data.MaxResolution
import com.sulkta.straw.data.SbCategory
import com.sulkta.straw.data.SearchCache
@ -176,6 +177,72 @@ fun SettingsScreen() {
HorizontalDivider()
}
Spacer(modifier = Modifier.height(32.dp))
Text(
"Autoplay",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"When a video ends with nothing left in the queue, what should " +
"play next? Queue auto-advance always works regardless of " +
"this setting — autoplay only kicks in at the end.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
val autoplayMode by store.autoplayMode.collectAsState()
AutoplayMode.entries.forEach { m ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { store.setAutoplayMode(m) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (m == autoplayMode) "${m.label}" else " ${m.label}",
style = MaterialTheme.typography.bodyLarge,
color = if (m == autoplayMode) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
)
}
Text(
m.help,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 24.dp, bottom = 4.dp),
)
HorizontalDivider()
}
Spacer(modifier = Modifier.height(12.dp))
val skipWatched by store.autoplaySkipWatched.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Skip already-watched videos",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
"Autoplay picks the next un-watched video.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = skipWatched,
onCheckedChange = { store.setAutoplaySkipWatched(it) },
)
}
Spacer(modifier = Modifier.height(32.dp))
Text(
"Local cache",