From e26a3eca19273f972539f2ad8e3ed6c30c4ddaae Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 09:04:50 -0700 Subject: [PATCH] vc=53: scrub-point store + auto-resume on video open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../main/kotlin/com/sulkta/straw/StrawApp.kt | 2 + .../sulkta/straw/data/ResumePositionsStore.kt | 146 ++++++++++++++++++ .../com/sulkta/straw/data/SettingsStore.kt | 20 +++ .../straw/feature/player/PlaybackService.kt | 48 ++++++ .../feature/player/StrawMediaController.kt | 16 +- .../straw/feature/settings/SettingsScreen.kt | 26 ++++ 7 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 9eff49449..3bd5dd502 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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 = 52 -const val STRAW_VERSION_NAME = "0.1.0-BL" +const val STRAW_VERSION_CODE = 53 +const val STRAW_VERSION_NAME = "0.1.0-BM" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 2868d58ac..58d75fa9f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt new file mode 100644 index 000000000..3b4fa5f57 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt @@ -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> = _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())).apply() + } + + private fun load(): Map = runCatching { + val s = sp.getString(KEY_POSITIONS, null) ?: return emptyMap() + json.decodeFromString>(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)") +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 05dbafae9..7ccab0543 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -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 = _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 = _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 { val raw = sp.getStringSet(KEY_SB_CATS, null) return if (raw == null) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 3cae0426a..692aebd04 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -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 } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt index 9edb2a3ab..923dcd095 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt @@ -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 { 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 } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index 784baa887..c8a11962a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -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(