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:
parent
02381edf03
commit
964bcddb3a
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
|
||||
// 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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ fun PlayerScreen(
|
|||
uploader = uploader,
|
||||
thumbnail = thumbnail,
|
||||
resolved = r,
|
||||
uploaderUrl = detail?.uploaderUrl,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue