vc=53: scrub-point store + auto-resume on video open
ResumePositionsStore — SharedPreferences-lite, JSON-blob keyed by videoId. Caps at 500 entries, prunes oldest on add. Skips trivial positions (< 5s) and clears near-end (within 5s of duration) so a finished video doesn't auto-resume to its credits. PlaybackService — 5s polling job + onIsPlayingChanged(false) + onDestroy capture write player position via NowPlaying.streamUrl → videoId. Runs on Main so the player read is thread-safe; store write is SP.apply() async. setPlayingFrom — when caller passes startPositionMs=0L AND Settings.autoResume is on, lookup the saved position and use it. Surface-handoff path (inline ↔ fullscreen) is untouched — MediaController already holds its own position across surfaces. This only fires on fresh opens (cold start, app update, video re-tap from history).
This commit is contained in:
parent
ebe1fc8464
commit
e26a3eca19
7 changed files with 259 additions and 3 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-Coop/strawcore) via the UniFFI wrapper; no
|
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
|
||||||
// NewPipeExtractor in the runtime path.
|
// NewPipeExtractor in the runtime path.
|
||||||
const val STRAW_VERSION_CODE = 52
|
const val STRAW_VERSION_CODE = 53
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-BL"
|
const val STRAW_VERSION_NAME = "0.1.0-BM"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import android.app.Application
|
||||||
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.Playlists
|
import com.sulkta.straw.data.Playlists
|
||||||
|
import com.sulkta.straw.data.Resume
|
||||||
import com.sulkta.straw.data.SearchCache
|
import com.sulkta.straw.data.SearchCache
|
||||||
import com.sulkta.straw.data.Settings
|
import com.sulkta.straw.data.Settings
|
||||||
import com.sulkta.straw.data.Subscriptions
|
import com.sulkta.straw.data.Subscriptions
|
||||||
|
|
@ -67,6 +68,7 @@ class StrawApp : Application() {
|
||||||
History.init(this)
|
History.init(this)
|
||||||
Subscriptions.init(this)
|
Subscriptions.init(this)
|
||||||
Playlists.init(this)
|
Playlists.init(this)
|
||||||
|
Resume.init(this)
|
||||||
// vc=36 audit HIGH-R3: FeedCache (~225 KB) + SearchCache
|
// vc=36 audit HIGH-R3: FeedCache (~225 KB) + SearchCache
|
||||||
// (~150 KB) JSON-decode at construction. Stash the
|
// (~150 KB) JSON-decode at construction. Stash the
|
||||||
// applicationContext eagerly (cheap) so `get()` is callable
|
// applicationContext eagerly (cheap) so `get()` is callable
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Per-video scrub-point store. App update / process death / device
|
||||||
|
* reboot — all three would otherwise lose the user's place in a long
|
||||||
|
* video. We write position every ~5s while playing + on every pause +
|
||||||
|
* on player teardown, keyed by videoId so resume works across stream
|
||||||
|
* URL rotations (googlevideo URLs rotate per session).
|
||||||
|
*
|
||||||
|
* SharedPreferences-lite, single JSON blob, capped at MAX_RESUMES with
|
||||||
|
* oldest-eviction. Same shape as HistoryStore — graduates to Room if a
|
||||||
|
* real query pattern shows up.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.sulkta.straw.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.updateAndGet
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ResumePosition(
|
||||||
|
val positionMs: Long,
|
||||||
|
val durationMs: Long,
|
||||||
|
val lastWatchedAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val PREFS = "straw_resume_positions"
|
||||||
|
private const val KEY_POSITIONS = "positions_v1"
|
||||||
|
|
||||||
|
/** Cap on retained per-video resume entries — prune oldest on overflow. */
|
||||||
|
private const val MAX_RESUMES = 500
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip writes for trivial positions — auto-resuming from 0:03 is more
|
||||||
|
* annoying than starting fresh. Mirrors YouTube's "near the start"
|
||||||
|
* threshold.
|
||||||
|
*/
|
||||||
|
private const val MIN_POSITION_MS = 5_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When position is within END_THRESHOLD of duration, treat the video as
|
||||||
|
* "done" and clear the entry instead of recording. Otherwise a finished
|
||||||
|
* watch would auto-resume to the credits next time.
|
||||||
|
*/
|
||||||
|
private const val END_THRESHOLD_MS = 5_000L
|
||||||
|
|
||||||
|
class ResumePositionsStore(context: Context) {
|
||||||
|
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private val _positions = MutableStateFlow(load())
|
||||||
|
val positions: StateFlow<Map<String, ResumePosition>> = _positions.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record (or update) the scrub-point for a video. Skipped silently
|
||||||
|
* when:
|
||||||
|
* - videoId is blank
|
||||||
|
* - durationMs <= 0 (live stream / unknown)
|
||||||
|
* - positionMs is below MIN_POSITION_MS (just started)
|
||||||
|
*
|
||||||
|
* When positionMs is within END_THRESHOLD_MS of the end the entry is
|
||||||
|
* REMOVED so a finished video doesn't auto-resume to its credits.
|
||||||
|
*/
|
||||||
|
fun record(videoId: String, positionMs: Long, durationMs: Long) {
|
||||||
|
if (videoId.isBlank()) return
|
||||||
|
if (durationMs <= 0L) return
|
||||||
|
if (positionMs < MIN_POSITION_MS) return
|
||||||
|
if (positionMs >= durationMs - END_THRESHOLD_MS) {
|
||||||
|
clearOne(videoId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val entry = ResumePosition(
|
||||||
|
positionMs = positionMs,
|
||||||
|
durationMs = durationMs,
|
||||||
|
lastWatchedAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
val before = _positions.value
|
||||||
|
val next = _positions.updateAndGet { current ->
|
||||||
|
val withEntry = current + (videoId to entry)
|
||||||
|
if (withEntry.size > MAX_RESUMES) {
|
||||||
|
// Drop oldest by lastWatchedAt — newcomers always land
|
||||||
|
// because the entry we just added is by definition the
|
||||||
|
// freshest. take(MAX_RESUMES) of the sorted-desc list.
|
||||||
|
withEntry.entries
|
||||||
|
.sortedByDescending { it.value.lastWatchedAt }
|
||||||
|
.take(MAX_RESUMES)
|
||||||
|
.associate { it.key to it.value }
|
||||||
|
} else {
|
||||||
|
withEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next !== before) {
|
||||||
|
sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns null when the video has no recorded position. */
|
||||||
|
fun get(videoId: String): ResumePosition? {
|
||||||
|
if (videoId.isBlank()) return null
|
||||||
|
return _positions.value[videoId]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearOne(videoId: String) {
|
||||||
|
if (videoId.isBlank()) return
|
||||||
|
val before = _positions.value
|
||||||
|
val next = _positions.updateAndGet { current ->
|
||||||
|
if (videoId !in current) current else current - videoId
|
||||||
|
}
|
||||||
|
if (next !== before) {
|
||||||
|
sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll() {
|
||||||
|
_positions.updateAndGet { emptyMap() }
|
||||||
|
sp.edit().putString(KEY_POSITIONS, json.encodeToString(emptyMap<String, ResumePosition>())).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(): Map<String, ResumePosition> = runCatching {
|
||||||
|
val s = sp.getString(KEY_POSITIONS, null) ?: return emptyMap()
|
||||||
|
json.decodeFromString<Map<String, ResumePosition>>(s)
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App-wide singleton; created in StrawApp.onCreate. */
|
||||||
|
object Resume {
|
||||||
|
@Volatile private var instance: ResumePositionsStore? = null
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized(this) {
|
||||||
|
if (instance == null) instance = ResumePositionsStore(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(): ResumePositionsStore = instance
|
||||||
|
?: error("ResumePositionsStore not initialized — call Resume.init(context)")
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,7 @@ private const val KEY_AUTOPLAY_MODE = "autoplay_mode_v1"
|
||||||
private const val KEY_AUTOPLAY_SKIP_WATCHED = "autoplay_skip_watched_v1"
|
private const val KEY_AUTOPLAY_SKIP_WATCHED = "autoplay_skip_watched_v1"
|
||||||
private const val KEY_AUTOSTART_PLAYBACK = "autostart_playback_v1"
|
private const val KEY_AUTOSTART_PLAYBACK = "autostart_playback_v1"
|
||||||
private const val KEY_PAUSE_ON_HEADPHONE_DISCONNECT = "pause_on_headphone_disconnect_v1"
|
private const val KEY_PAUSE_ON_HEADPHONE_DISCONNECT = "pause_on_headphone_disconnect_v1"
|
||||||
|
private const val KEY_AUTO_RESUME = "auto_resume_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)
|
||||||
|
|
@ -116,6 +117,18 @@ class SettingsStore(context: Context) {
|
||||||
)
|
)
|
||||||
val pauseOnHeadphoneDisconnect: StateFlow<Boolean> = _pauseOnHeadphoneDisconnect.asStateFlow()
|
val pauseOnHeadphoneDisconnect: StateFlow<Boolean> = _pauseOnHeadphoneDisconnect.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-resume scrub-point on video open. When on (default), opening
|
||||||
|
* a video that has a saved position picks up where the user left
|
||||||
|
* off. When off, every open starts at 0:00. Doesn't affect inline-
|
||||||
|
* ↔ fullscreen hand-off (the shared MediaController keeps its own
|
||||||
|
* position across surfaces; this only matters on fresh opens).
|
||||||
|
*/
|
||||||
|
private val _autoResume = MutableStateFlow(
|
||||||
|
sp.getBoolean(KEY_AUTO_RESUME, true),
|
||||||
|
)
|
||||||
|
val autoResume: StateFlow<Boolean> = _autoResume.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 ->
|
||||||
|
|
@ -178,6 +191,13 @@ class SettingsStore(context: Context) {
|
||||||
sp.edit().putBoolean(KEY_PAUSE_ON_HEADPHONE_DISCONNECT, pause).apply()
|
sp.edit().putBoolean(KEY_PAUSE_ON_HEADPHONE_DISCONNECT, pause).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAutoResume(enabled: Boolean) {
|
||||||
|
val before = _autoResume.value
|
||||||
|
if (before == enabled) return
|
||||||
|
_autoResume.value = enabled
|
||||||
|
sp.edit().putBoolean(KEY_AUTO_RESUME, enabled).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) {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import com.sulkta.straw.StrawActivity
|
||||||
import com.sulkta.straw.StrawApp
|
import com.sulkta.straw.StrawApp
|
||||||
import com.sulkta.straw.data.AutoplayMode
|
import com.sulkta.straw.data.AutoplayMode
|
||||||
import com.sulkta.straw.data.History
|
import com.sulkta.straw.data.History
|
||||||
|
import com.sulkta.straw.data.Resume
|
||||||
import com.sulkta.straw.data.Settings
|
import com.sulkta.straw.data.Settings
|
||||||
import com.sulkta.straw.feature.detail.resolveStreamPlayback
|
import com.sulkta.straw.feature.detail.resolveStreamPlayback
|
||||||
import com.sulkta.straw.net.IosSafeHttpDataSource
|
import com.sulkta.straw.net.IosSafeHttpDataSource
|
||||||
|
|
@ -56,7 +57,9 @@ import com.sulkta.straw.net.SponsorBlockClient
|
||||||
import com.sulkta.straw.util.runCatchingCancellable
|
import com.sulkta.straw.util.runCatchingCancellable
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
|
@ -65,6 +68,7 @@ class PlaybackService : MediaSessionService() {
|
||||||
|
|
||||||
private var mediaSession: MediaSession? = null
|
private var mediaSession: MediaSession? = null
|
||||||
private var settingsWatcherJob: Job? = null
|
private var settingsWatcherJob: Job? = null
|
||||||
|
private var resumePollJob: Job? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
@ -162,6 +166,13 @@ class PlaybackService : MediaSessionService() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
// Capture on every play→pause edge. Covers user taps,
|
||||||
|
// audio focus loss, headphone-noisy pause. The 5s poll
|
||||||
|
// covers the play-through case.
|
||||||
|
if (!isPlaying) captureResumePosition(player)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(state: Int) {
|
override fun onPlaybackStateChanged(state: Int) {
|
||||||
if (state != Player.STATE_ENDED) return
|
if (state != Player.STATE_ENDED) return
|
||||||
val mode = Settings.get().autoplayMode.value
|
val mode = Settings.get().autoplayMode.value
|
||||||
|
|
@ -176,6 +187,34 @@ class PlaybackService : MediaSessionService() {
|
||||||
tryAutoplay(mode)
|
tryAutoplay(mode)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Periodic scrub-point write. Stays on Main so player reads are
|
||||||
|
// thread-safe; the SP write inside record() is async (apply()).
|
||||||
|
// 5s cadence is the sweet spot — finer is wasted disk churn,
|
||||||
|
// coarser loses too much on a sudden process death.
|
||||||
|
resumePollJob = StrawApp.globalScope.launch(Dispatchers.Main) {
|
||||||
|
while (isActive) {
|
||||||
|
delay(RESUME_POLL_INTERVAL_MS)
|
||||||
|
captureResumePosition(player)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current player position and persist it to the
|
||||||
|
* ResumePositionsStore. Bails on idle/ended states and unknown
|
||||||
|
* durations (live streams). The store itself enforces minimum-
|
||||||
|
* position + near-end-clear thresholds.
|
||||||
|
*/
|
||||||
|
private fun captureResumePosition(player: Player) {
|
||||||
|
val state = player.playbackState
|
||||||
|
if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) return
|
||||||
|
val item = NowPlaying.current.value ?: return
|
||||||
|
val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(item.streamUrl) ?: return
|
||||||
|
val pos = player.currentPosition
|
||||||
|
val dur = player.duration
|
||||||
|
if (dur <= 0L) return
|
||||||
|
Resume.get().record(videoId, pos, dur)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchSbForQueued(item: NowPlayingItem, videoId: String) {
|
private fun fetchSbForQueued(item: NowPlayingItem, videoId: String) {
|
||||||
|
|
@ -287,6 +326,12 @@ class PlaybackService : MediaSessionService() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
// Final scrub-point snapshot before teardown — covers swipe-
|
||||||
|
// away-without-pause case. Read before cancelling the poll
|
||||||
|
// job (the job's last tick may not have landed yet).
|
||||||
|
mediaSession?.player?.let { captureResumePosition(it) }
|
||||||
|
resumePollJob?.cancel()
|
||||||
|
resumePollJob = null
|
||||||
settingsWatcherJob?.cancel()
|
settingsWatcherJob?.cancel()
|
||||||
settingsWatcherJob = null
|
settingsWatcherJob = null
|
||||||
// Null the field first so a late onGetSession during teardown gets
|
// Null the field first so a late onGetSession during teardown gets
|
||||||
|
|
@ -307,6 +352,9 @@ class PlaybackService : MediaSessionService() {
|
||||||
* MediaItem's video URI to produce a combined video+audio source.
|
* MediaItem's video URI to produce a combined video+audio source.
|
||||||
*/
|
*/
|
||||||
const val EXTRA_AUDIO_URL = "straw.audio_url"
|
const val EXTRA_AUDIO_URL = "straw.audio_url"
|
||||||
|
|
||||||
|
/** Scrub-point write cadence while the player is alive. */
|
||||||
|
private const val RESUME_POLL_INTERVAL_MS = 5_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,10 @@ 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.data.Resume
|
||||||
import com.sulkta.straw.data.Settings
|
import com.sulkta.straw.data.Settings
|
||||||
import com.sulkta.straw.feature.detail.ResolvedPlayback
|
import com.sulkta.straw.feature.detail.ResolvedPlayback
|
||||||
|
import com.sulkta.straw.feature.detail.extractYtVideoId
|
||||||
|
|
||||||
val LocalStrawController = compositionLocalOf<MediaController?> { null }
|
val LocalStrawController = compositionLocalOf<MediaController?> { null }
|
||||||
|
|
||||||
|
|
@ -113,7 +115,19 @@ fun Player.setPlayingFrom(
|
||||||
// tells the ABR algorithm to never pick anything taller than
|
// tells the ABR algorithm to never pick anything taller than
|
||||||
// ceiling. Auto = Int.MAX_VALUE = no constraint.
|
// ceiling. Auto = Int.MAX_VALUE = no constraint.
|
||||||
applyMaxResolutionCap()
|
applyMaxResolutionCap()
|
||||||
setMediaItem(mediaItem, startPositionMs)
|
// Auto-resume: when the caller passed the default 0L and
|
||||||
|
// Settings.autoResume is on, look up the saved scrub-point for
|
||||||
|
// this videoId. Lets the user pick up where they left off after
|
||||||
|
// an app update / process death. The store skips trivial
|
||||||
|
// positions and clears near-end so we don't auto-resume to 0:03
|
||||||
|
// or to the credits.
|
||||||
|
val effectiveStart = if (startPositionMs == 0L && Settings.get().autoResume.value) {
|
||||||
|
val videoId = extractYtVideoId(streamUrl)
|
||||||
|
videoId?.let { Resume.get().get(it)?.positionMs } ?: 0L
|
||||||
|
} else {
|
||||||
|
startPositionMs
|
||||||
|
}
|
||||||
|
setMediaItem(mediaItem, effectiveStart)
|
||||||
prepare()
|
prepare()
|
||||||
playWhenReady = true
|
playWhenReady = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,32 @@ fun SettingsScreen() {
|
||||||
onCheckedChange = { store.setPauseOnHeadphoneDisconnect(it) },
|
onCheckedChange = { store.setPauseOnHeadphoneDisconnect(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val autoResume by store.autoResume.collectAsState()
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
"Resume where you left off",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Reopen a video → pick up at the saved scrub-point. " +
|
||||||
|
"Off: every open starts at 0:00.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = autoResume,
|
||||||
|
onCheckedChange = { store.setAutoResume(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue