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 a tester 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:
parent
5f02b050e0
commit
a6db9ccc19
9 changed files with 295 additions and 9 deletions
|
|
@ -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 = 47
|
const val STRAW_VERSION_CODE = 48
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-BG"
|
const val STRAW_VERSION_NAME = "0.1.0-BH"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,28 @@ enum class ThemeMode(val label: String) {
|
||||||
Dark("Dark"),
|
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 PREFS = "straw_settings"
|
||||||
private const val KEY_SB_CATS = "sb_categories_v1"
|
private const val KEY_SB_CATS = "sb_categories_v1"
|
||||||
private const val KEY_MAX_RES = "max_resolution_v1"
|
private const val KEY_MAX_RES = "max_resolution_v1"
|
||||||
private const val KEY_THEME = "theme_mode_v1"
|
private const val KEY_THEME = "theme_mode_v1"
|
||||||
private const val KEY_CACHE_ENABLED = "cache_enabled_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) {
|
class SettingsStore(context: Context) {
|
||||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
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))
|
private val _cacheEnabled = MutableStateFlow(sp.getBoolean(KEY_CACHE_ENABLED, true))
|
||||||
val cacheEnabled: StateFlow<Boolean> = _cacheEnabled.asStateFlow()
|
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) {
|
fun toggle(cat: SbCategory) {
|
||||||
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
|
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
|
||||||
val next = _sbCategories.updateAndGet { cur ->
|
val next = _sbCategories.updateAndGet { cur ->
|
||||||
|
|
@ -98,6 +123,20 @@ class SettingsStore(context: Context) {
|
||||||
sp.edit().putBoolean(KEY_CACHE_ENABLED, enabled).apply()
|
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> {
|
private fun loadCategories(): Set<SbCategory> {
|
||||||
val raw = sp.getStringSet(KEY_SB_CATS, null)
|
val raw = sp.getStringSet(KEY_SB_CATS, null)
|
||||||
return if (raw == null) {
|
return if (raw == null) {
|
||||||
|
|
@ -117,6 +156,14 @@ class SettingsStore(context: Context) {
|
||||||
val name = sp.getString(KEY_THEME, null) ?: return ThemeMode.System
|
val name = sp.getString(KEY_THEME, null) ?: return ThemeMode.System
|
||||||
return ThemeMode.entries.firstOrNull { it.name == name } ?: 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 {
|
object Settings {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,21 @@ package com.sulkta.straw.feature.detail
|
||||||
import com.sulkta.straw.data.Settings
|
import com.sulkta.straw.data.Settings
|
||||||
import com.sulkta.straw.net.SbSegment
|
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
|
* Convert a raw strawcore.StreamInfo into the picked-URL DTO the
|
||||||
* MediaController wants. Honors Settings.maxResolution — cap-fit if
|
* MediaController wants. Honors Settings.maxResolution — cap-fit if
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,7 @@ fun VideoDetailScreen(
|
||||||
uploader = d.uploader,
|
uploader = d.uploader,
|
||||||
thumbnail = d.thumbnail,
|
thumbnail = d.thumbnail,
|
||||||
resolved = r,
|
resolved = r,
|
||||||
|
uploaderUrl = d.uploaderUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Audio-only: drop video track. Foreground
|
// Audio-only: drop video track. Foreground
|
||||||
|
|
@ -488,6 +489,7 @@ fun VideoDetailScreen(
|
||||||
uploader = d.uploader,
|
uploader = d.uploader,
|
||||||
thumbnail = d.thumbnail,
|
thumbnail = d.thumbnail,
|
||||||
resolved = r,
|
resolved = r,
|
||||||
|
uploaderUrl = d.uploaderUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val params = PictureInPictureParams.Builder()
|
val params = PictureInPictureParams.Builder()
|
||||||
|
|
@ -746,6 +748,7 @@ private fun InlinePlayer(
|
||||||
uploader = uploader,
|
uploader = uploader,
|
||||||
thumbnail = thumbnail,
|
thumbnail = thumbnail,
|
||||||
resolved = r,
|
resolved = r,
|
||||||
|
uploaderUrl = state.detail?.uploaderUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,14 @@ data class NowPlayingItem(
|
||||||
val streamUrl: String,
|
val streamUrl: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val uploader: 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 thumbnail: String?,
|
||||||
val segments: List<SbSegment> = emptyList(),
|
val segments: List<SbSegment> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,18 @@ import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import androidx.media3.session.MediaSessionService
|
import androidx.media3.session.MediaSessionService
|
||||||
import com.sulkta.straw.StrawActivity
|
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.IosSafeHttpDataSource
|
||||||
import com.sulkta.straw.net.STRAW_USER_AGENT
|
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
|
@UnstableApi
|
||||||
class PlaybackService : MediaSessionService() {
|
class PlaybackService : MediaSessionService() {
|
||||||
|
|
@ -105,16 +115,144 @@ class PlaybackService : MediaSessionService() {
|
||||||
// and push it into NowPlaying so the minibar + SponsorBlock
|
// and push it into NowPlaying so the minibar + SponsorBlock
|
||||||
// skip-loop reflect the new track. claim() handles concurrent
|
// skip-loop reflect the new track. claim() handles concurrent
|
||||||
// setPlayingFrom races — see vc=35 audit HIGH-C6.
|
// 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 {
|
player.addListener(object : Player.Listener {
|
||||||
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
|
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
|
||||||
if (item == null) return
|
if (item == null) return
|
||||||
val idx = player.currentMediaItemIndex
|
val idx = player.currentMediaItemIndex
|
||||||
val queued = Queue.at(idx) ?: return
|
val queued = Queue.at(idx) ?: return
|
||||||
NowPlaying.claim(queued)
|
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(
|
override fun onGetSession(
|
||||||
controllerInfo: MediaSession.ControllerInfo,
|
controllerInfo: MediaSession.ControllerInfo,
|
||||||
): MediaSession? = mediaSession
|
): MediaSession? = mediaSession
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ fun PlayerScreen(
|
||||||
uploader = uploader,
|
uploader = uploader,
|
||||||
thumbnail = thumbnail,
|
thumbnail = thumbnail,
|
||||||
resolved = r,
|
resolved = r,
|
||||||
|
uploaderUrl = detail?.uploaderUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
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
|
||||||
|
|
@ -74,19 +75,21 @@ fun rememberStrawController(): MediaController? {
|
||||||
* MediaSource based on MIME + the EXTRA_AUDIO_URL bundle.
|
* MediaSource based on MIME + the EXTRA_AUDIO_URL bundle.
|
||||||
*/
|
*/
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
fun MediaController.setPlayingFrom(
|
fun Player.setPlayingFrom(
|
||||||
streamUrl: String,
|
streamUrl: String,
|
||||||
title: String,
|
title: String,
|
||||||
uploader: String,
|
uploader: String,
|
||||||
thumbnail: String?,
|
thumbnail: String?,
|
||||||
resolved: ResolvedPlayback,
|
resolved: ResolvedPlayback,
|
||||||
startPositionMs: Long = 0L,
|
startPositionMs: Long = 0L,
|
||||||
|
uploaderUrl: String? = null,
|
||||||
) {
|
) {
|
||||||
val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return
|
val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return
|
||||||
val nowPlayingItem = NowPlayingItem(
|
val nowPlayingItem = NowPlayingItem(
|
||||||
streamUrl = streamUrl,
|
streamUrl = streamUrl,
|
||||||
title = title,
|
title = title,
|
||||||
uploader = uploader,
|
uploader = uploader,
|
||||||
|
uploaderUrl = uploaderUrl,
|
||||||
thumbnail = thumbnail,
|
thumbnail = thumbnail,
|
||||||
segments = resolved.segments,
|
segments = resolved.segments,
|
||||||
)
|
)
|
||||||
|
|
@ -116,31 +119,34 @@ fun MediaController.setPlayingFrom(
|
||||||
* failure (no playable stream in `resolved`).
|
* failure (no playable stream in `resolved`).
|
||||||
*/
|
*/
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
fun MediaController.enqueueNext(
|
fun Player.enqueueNext(
|
||||||
streamUrl: String,
|
streamUrl: String,
|
||||||
title: String,
|
title: String,
|
||||||
uploader: String,
|
uploader: String,
|
||||||
thumbnail: String?,
|
thumbnail: String?,
|
||||||
resolved: ResolvedPlayback,
|
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. */
|
/** Append to the back of the queue. Same idle-fallback as enqueueNext. */
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
fun MediaController.enqueueLast(
|
fun Player.enqueueLast(
|
||||||
streamUrl: String,
|
streamUrl: String,
|
||||||
title: String,
|
title: String,
|
||||||
uploader: String,
|
uploader: String,
|
||||||
thumbnail: String?,
|
thumbnail: String?,
|
||||||
resolved: ResolvedPlayback,
|
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
|
@UnstableApi
|
||||||
private fun MediaController.enqueueInternal(
|
private fun Player.enqueueInternal(
|
||||||
streamUrl: String,
|
streamUrl: String,
|
||||||
title: String,
|
title: String,
|
||||||
uploader: String,
|
uploader: String,
|
||||||
thumbnail: String?,
|
thumbnail: String?,
|
||||||
resolved: ResolvedPlayback,
|
resolved: ResolvedPlayback,
|
||||||
|
uploaderUrl: String?,
|
||||||
asNext: Boolean,
|
asNext: Boolean,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return false
|
val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return false
|
||||||
|
|
@ -148,13 +154,14 @@ private fun MediaController.enqueueInternal(
|
||||||
streamUrl = streamUrl,
|
streamUrl = streamUrl,
|
||||||
title = title,
|
title = title,
|
||||||
uploader = uploader,
|
uploader = uploader,
|
||||||
|
uploaderUrl = uploaderUrl,
|
||||||
thumbnail = thumbnail,
|
thumbnail = thumbnail,
|
||||||
segments = resolved.segments,
|
segments = resolved.segments,
|
||||||
)
|
)
|
||||||
// Empty queue — there's nothing to "enqueue" onto. Treat as a
|
// Empty queue — there's nothing to "enqueue" onto. Treat as a
|
||||||
// start-playing-now and route through the normal claim path.
|
// start-playing-now and route through the normal claim path.
|
||||||
if (mediaItemCount == 0) {
|
if (mediaItemCount == 0) {
|
||||||
setPlayingFrom(streamUrl, title, uploader, thumbnail, resolved)
|
setPlayingFrom(streamUrl, title, uploader, thumbnail, resolved, uploaderUrl = uploaderUrl)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
val insertIndex = if (asNext) currentMediaItemIndex + 1 else mediaItemCount
|
val insertIndex = if (asNext) currentMediaItemIndex + 1 else mediaItemCount
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import android.widget.Toast
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.sulkta.straw.data.FeedCache
|
import com.sulkta.straw.data.FeedCache
|
||||||
import com.sulkta.straw.data.History
|
import com.sulkta.straw.data.History
|
||||||
|
import com.sulkta.straw.data.AutoplayMode
|
||||||
import com.sulkta.straw.data.MaxResolution
|
import com.sulkta.straw.data.MaxResolution
|
||||||
import com.sulkta.straw.data.SbCategory
|
import com.sulkta.straw.data.SbCategory
|
||||||
import com.sulkta.straw.data.SearchCache
|
import com.sulkta.straw.data.SearchCache
|
||||||
|
|
@ -176,6 +177,72 @@ fun SettingsScreen() {
|
||||||
HorizontalDivider()
|
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))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
Text(
|
Text(
|
||||||
"Local cache",
|
"Local cache",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue