diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index d0bd6a3da..a155f1f75 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 6f01965ac..2580f8128 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -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 = _cacheEnabled.asStateFlow() + private val _autoplayMode = MutableStateFlow(loadAutoplayMode()) + val autoplayMode: StateFlow = _autoplayMode.asStateFlow() + + private val _autoplaySkipWatched = MutableStateFlow( + sp.getBoolean(KEY_AUTOPLAY_SKIP_WATCHED, true), + ) + val autoplaySkipWatched: StateFlow = _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 { 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 { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt index 05325bffd..d3daaac3e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt @@ -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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 8cb277a6c..fb2f9aaef 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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, ) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt index 446729753..3732b9212 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt @@ -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 = emptyList(), ) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 7af5a161f..7ddd1a85b 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 4d455a47a..fb466eebc 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -117,6 +117,7 @@ fun PlayerScreen( uploader = uploader, thumbnail = thumbnail, resolved = r, + uploaderUrl = detail?.uploaderUrl, ) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt index 127488aa8..1932f1726 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt @@ -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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index 332e5b2f3..8d6a75551 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -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",