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:
Kayos 2026-05-26 09:04:50 -07:00
parent ebe1fc8464
commit e26a3eca19
7 changed files with 259 additions and 3 deletions

View file

@ -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"

View file

@ -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

View file

@ -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)")
}

View file

@ -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) {

View file

@ -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
} }
} }

View file

@ -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
} }

View file

@ -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(