From 327e68af1900a88b45cdeb1353842d09fcd2c7ee Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Fri, 12 Jun 2026 12:30:23 +0000 Subject: [PATCH] fix(playback): resume offline playback from saved position instead of start Offline downloaded media always started playback from the beginning because getOfflineDownloadedPlayableMedia() hardcoded resumePositionMs to 0L, ignoring the progress percentage stored in the Room database. Added calculateOfflineResumePosition() that parses the runtime string and converts the stored progress percentage (0-100) to milliseconds, applying the same 5%-95% threshold as the online path. --- .../DefaultPlayableMediaRepository.kt | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt index 7a786002..fdf68507 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt @@ -37,6 +37,7 @@ import org.jellyfin.sdk.model.api.MediaSegmentType.RECAP import org.jellyfin.sdk.model.api.MediaSourceInfo import timber.log.Timber import java.util.UUID +import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -158,7 +159,7 @@ class DefaultPlayableMediaRepository @Inject constructor( artworkUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY), playbackReportContext = null, ), - resumePositionMs = 0L, + resumePositionMs = calculateOfflineResumePosition(movie.progress, movie.runtime, movie.watched), preferences = preferences, mediaSegments = emptyList() ) @@ -176,7 +177,7 @@ class DefaultPlayableMediaRepository @Inject constructor( artworkUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY), playbackReportContext = null, ), - resumePositionMs = 0L, + resumePositionMs = calculateOfflineResumePosition(episode.progress, episode.runtime, episode.watched), preferences = preferences, mediaSegments = emptyList() ) @@ -326,6 +327,39 @@ class DefaultPlayableMediaRepository @Inject constructor( .build() } + /** + * Calculates the resume position for offline media from the stored progress percentage + * and runtime string. Applies the same 5%-95% threshold logic as [calculateResumePosition]. + */ + private fun calculateOfflineResumePosition( + progress: Double?, + runtime: String, + watched: Boolean, + ): Long { + if (watched) return 0L + val progressPercent = progress ?: return 0L + if (progressPercent.isNaN() || progressPercent <= 0.0) return 0L + + val runtimeMs = parseRuntimeToMs(runtime) ?: return 0L + if (runtimeMs <= 0L) return 0L + + val positionMs = ((progressPercent / 100.0) * runtimeMs).toLong() + return if (progressPercent in 5.0..95.0) positionMs else 0L + } + + /** + * Parses a runtime string in the format "2h 1m" or "25m" (produced by [formatRuntime]) + * into milliseconds. Returns null for unparseable values like "—". + */ + private fun parseRuntimeToMs(runtime: String): Long? { + if (runtime.isBlank() || runtime == "—") return null + val regex = Regex("""(?:(\d+)h\s*)?(\d+)m""") + val match = regex.matchEntire(runtime.trim()) ?: return null + val hours = match.groupValues[1].toLongOrNull() ?: 0L + val minutes = match.groupValues[2].toLongOrNull() ?: 0L + return TimeUnit.MINUTES.toMillis(hours * 60 + minutes) + } + private fun MediaSegmentDto.toMediaSegment(): MediaSegment { val segmentType = when (type) { INTRO -> SegmentType.INTRO