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
|
|
@ -9,6 +9,7 @@ import android.app.Application
|
|||
import com.sulkta.straw.data.FeedCache
|
||||
import com.sulkta.straw.data.History
|
||||
import com.sulkta.straw.data.Playlists
|
||||
import com.sulkta.straw.data.Resume
|
||||
import com.sulkta.straw.data.SearchCache
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.data.Subscriptions
|
||||
|
|
@ -67,6 +68,7 @@ class StrawApp : Application() {
|
|||
History.init(this)
|
||||
Subscriptions.init(this)
|
||||
Playlists.init(this)
|
||||
Resume.init(this)
|
||||
// vc=36 audit HIGH-R3: FeedCache (~225 KB) + SearchCache
|
||||
// (~150 KB) JSON-decode at construction. Stash the
|
||||
// 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_AUTOSTART_PLAYBACK = "autostart_playback_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) {
|
||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
|
@ -116,6 +117,18 @@ class SettingsStore(context: Context) {
|
|||
)
|
||||
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) {
|
||||
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
|
||||
val next = _sbCategories.updateAndGet { cur ->
|
||||
|
|
@ -178,6 +191,13 @@ class SettingsStore(context: Context) {
|
|||
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> {
|
||||
val raw = sp.getStringSet(KEY_SB_CATS, null)
|
||||
return if (raw == null) {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ 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.Resume
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.feature.detail.resolveStreamPlayback
|
||||
import com.sulkta.straw.net.IosSafeHttpDataSource
|
||||
|
|
@ -56,7 +57,9 @@ import com.sulkta.straw.net.SponsorBlockClient
|
|||
import com.sulkta.straw.util.runCatchingCancellable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
|
@ -65,6 +68,7 @@ class PlaybackService : MediaSessionService() {
|
|||
|
||||
private var mediaSession: MediaSession? = null
|
||||
private var settingsWatcherJob: Job? = null
|
||||
private var resumePollJob: Job? = null
|
||||
|
||||
override fun 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) {
|
||||
if (state != Player.STATE_ENDED) return
|
||||
val mode = Settings.get().autoplayMode.value
|
||||
|
|
@ -176,6 +187,34 @@ class PlaybackService : MediaSessionService() {
|
|||
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) {
|
||||
|
|
@ -287,6 +326,12 @@ class PlaybackService : MediaSessionService() {
|
|||
}
|
||||
|
||||
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 = null
|
||||
// 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.
|
||||
*/
|
||||
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.SessionToken
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import com.sulkta.straw.data.Resume
|
||||
import com.sulkta.straw.data.Settings
|
||||
import com.sulkta.straw.feature.detail.ResolvedPlayback
|
||||
import com.sulkta.straw.feature.detail.extractYtVideoId
|
||||
|
||||
val LocalStrawController = compositionLocalOf<MediaController?> { null }
|
||||
|
||||
|
|
@ -113,7 +115,19 @@ fun Player.setPlayingFrom(
|
|||
// tells the ABR algorithm to never pick anything taller than
|
||||
// ceiling. Auto = Int.MAX_VALUE = no constraint.
|
||||
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()
|
||||
playWhenReady = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -304,6 +304,32 @@ fun SettingsScreen() {
|
|||
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))
|
||||
Text(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue