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.
This commit is contained in:
2026-06-12 12:30:23 +00:00
parent 8dcb6aeea9
commit 327e68af19

View File

@@ -37,6 +37,7 @@ import org.jellyfin.sdk.model.api.MediaSegmentType.RECAP
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -158,7 +159,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
artworkUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY), artworkUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY),
playbackReportContext = null, playbackReportContext = null,
), ),
resumePositionMs = 0L, resumePositionMs = calculateOfflineResumePosition(movie.progress, movie.runtime, movie.watched),
preferences = preferences, preferences = preferences,
mediaSegments = emptyList() mediaSegments = emptyList()
) )
@@ -176,7 +177,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
artworkUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY), artworkUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
playbackReportContext = null, playbackReportContext = null,
), ),
resumePositionMs = 0L, resumePositionMs = calculateOfflineResumePosition(episode.progress, episode.runtime, episode.watched),
preferences = preferences, preferences = preferences,
mediaSegments = emptyList() mediaSegments = emptyList()
) )
@@ -326,6 +327,39 @@ class DefaultPlayableMediaRepository @Inject constructor(
.build() .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 { private fun MediaSegmentDto.toMediaSegment(): MediaSegment {
val segmentType = when (type) { val segmentType = when (type) {
INTRO -> SegmentType.INTRO INTRO -> SegmentType.INTRO