Continue watching feed, watched marks, intro skipping
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user