Continue watching feed, watched marks, intro skipping

This commit is contained in:
2026-10-24 19:33:48 +03:00
parent 4b8427310f
commit 33643707f1
22 changed files with 1113 additions and 58 deletions

View File

@@ -3,6 +3,7 @@ package ru.stenfredd.movies.core.data
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.mediaSegmentApi
import org.jellyfin.sdk.api.client.extensions.personApi
import org.jellyfin.sdk.api.client.extensions.showApi
import org.jellyfin.sdk.api.client.extensions.userViewApi
@@ -18,6 +19,7 @@ import ru.stenfredd.movies.core.data.model.ItemSort
import ru.stenfredd.movies.core.data.model.ItemSummary
import ru.stenfredd.movies.core.data.model.LibraryType
import ru.stenfredd.movies.core.data.model.LibraryView
import ru.stenfredd.movies.core.data.model.MediaSegment
import ru.stenfredd.movies.core.data.model.Page
import ru.stenfredd.movies.core.data.model.Person
import ru.stenfredd.movies.core.data.model.itemKinds
@@ -101,6 +103,11 @@ class LibraryRepository @Inject constructor(
session.call { libraryApi.getItem(itemId = id, userId = userId).content }.toDetails()
}
/** Intro, credits and other skippable parts of [id] in time order; empty when the server has none. */
suspend fun segments(id: UUID): List<MediaSegment> = cache.get("segments:$id", Kind.Stable) {
session.call { mediaSegmentApi.getItemSegments(itemId = id).content.items }.mapNotNull { it.toSegment() }.sortedBy { it.startMs }
}
suspend fun seasons(seriesId: UUID): List<ItemSummary> = cache.get("seasons:$seriesId", Kind.UserData) {
session.call { showApi.getSeasons(seriesId = seriesId, userId = userId, fields = CARD_FIELDS).content.items }.map { it.toSummary() }
}

View File

@@ -7,6 +7,8 @@ import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
@@ -21,8 +23,10 @@ import ru.stenfredd.movies.core.data.model.ItemSummary
import ru.stenfredd.movies.core.data.model.LibraryType
import ru.stenfredd.movies.core.data.model.LibraryView
import ru.stenfredd.movies.core.data.model.MediaInfo
import ru.stenfredd.movies.core.data.model.MediaSegment
import ru.stenfredd.movies.core.data.model.Person
import ru.stenfredd.movies.core.data.model.PersonKind
import ru.stenfredd.movies.core.data.model.SegmentKind
import ru.stenfredd.movies.core.data.model.SubtitleTrack
import ru.stenfredd.movies.core.data.model.UserState
import ru.stenfredd.movies.core.data.model.VideoRange
@@ -100,6 +104,19 @@ internal fun BaseItemDto.toDetails(): ItemDetails = ItemDetails(
chapters = chapters.orEmpty().map { Chapter(it.name.orEmpty(), it.startPositionTicks / TICKS_PER_MS) },
)
/** null for segments of unknown kind and empty ones. */
internal fun MediaSegmentDto.toSegment(): MediaSegment? {
val kind = when (type) {
MediaSegmentType.INTRO -> SegmentKind.Intro
MediaSegmentType.RECAP -> SegmentKind.Recap
MediaSegmentType.PREVIEW -> SegmentKind.Preview
MediaSegmentType.OUTRO -> SegmentKind.Outro
MediaSegmentType.COMMERCIAL -> SegmentKind.Commercial
MediaSegmentType.UNKNOWN -> return null
}
return MediaSegment(kind, startTicks / TICKS_PER_MS, endTicks / TICKS_PER_MS).takeIf { it.endMs > it.startMs }
}
internal fun BaseItemDto.toLibraryView(): LibraryView = LibraryView(
id = id,
name = name.orEmpty(),

View File

@@ -43,15 +43,15 @@ class WatchRepository @Inject constructor(
}
/**
* "Continue watching": started items first (most recent first), then the next episode of each series in progress
* that isn't already there. Of one library when [parentId] is given.
* "Continue watching", one card per movie or series: started items first (most recent first; of a series only its
* most recently watched episode), then the next episode of each series in progress that has none started.
* Of one library when [parentId] is given.
*/
suspend fun continueWatching(limit: Int = 20, parentId: UUID? = null): List<ItemSummary> = coroutineScope {
val resume = async { resume(limit, parentId) }
// Several started episodes of a series fold into one card: fetch more so the list still fills up.
val resume = async { resume(limit * RESUME_PER_CARD, parentId) }
val nextUp = async { nextUp(limit, parentId) }
val started = resume.await()
val seriesStarted = started.mapNotNullTo(HashSet()) { it.seriesId }
(started + nextUp.await().filter { it.seriesId !in seriesStarted && started.none { s -> s.id == it.id } }).take(limit)
(resume.await() + nextUp.await()).distinctBy { it.seriesId ?: it.id }.take(limit)
}
/** Watched movies and episodes, most recent first; of one library when [parentId] is given. */
@@ -82,5 +82,7 @@ class WatchRepository @Inject constructor(
private companion object {
const val FAVORITES_LIMIT = 500
/** Started items fetched per "continue watching" card. */
const val RESUME_PER_CARD = 3
}
}

View File

@@ -128,6 +128,21 @@ data class ItemDetails(
data class Chapter(val name: String, val startMs: Long)
/** A part of a movie or an episode the player can skip, from the server's Media Segments (e.g. the Intro Skipper plugin). */
data class MediaSegment(val kind: SegmentKind, val startMs: Long, val endMs: Long)
enum class SegmentKind {
/** Opening, title sequence. */
Intro,
/** "Previously on". */
Recap,
/** A look at the next episode. */
Preview,
/** Credits, ending. */
Outro,
Commercial,
}
/** The file of a playable item: what the format badges and the "file" line show, and the track choices. */
data class MediaInfo(
val sourceId: String,

View File

@@ -65,6 +65,10 @@ class JellyfinSession @Inject constructor(
private val _session = MutableStateFlow<SessionInfo?>(null)
val session: StateFlow<SessionInfo?> = _session.asStateFlow()
private val _restored = MutableStateFlow(false)
/** True once [restore] has run: before that a null [session] means "not read yet", not "signed out". */
val restored: StateFlow<Boolean> = _restored.asStateFlow()
private val _accounts = MutableStateFlow<List<Account>>(emptyList())
/** Saved accounts, most recently used first. */
val accounts: StateFlow<List<Account>> = _accounts.asStateFlow()
@@ -125,9 +129,10 @@ class JellyfinSession @Inject constructor(
currentId = account.id
}
_accounts.value = accounts.sortedByDescending { it.lastUsedAt }
val current = accounts.firstOrNull { it.id == currentId } ?: return false
activate(current)
true
val current = accounts.firstOrNull { it.id == currentId }
current?.let(::activate)
_restored.value = true
current != null
}
/** Saves the account of a successful login (see [ServerLogin]) and makes it current. */