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,11 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- The TV's own home screen takes "Продолжить просмотр" from the app (LauncherFeed). -->
<queries>
<provider android:authorities="ru.stenfredd.tvhome.feed" />
</queries>
<uses-feature android:name="android.software.leanback" android:required="false" /> <uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" /> <uses-feature android:name="android.hardware.touchscreen" android:required="false" />
@@ -21,6 +26,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|uiMode" android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
android:exported="true" android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="landscape" android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing"> android:windowSoftInputMode="adjustNothing">
<intent-filter> <intent-filter>

View File

@@ -0,0 +1,154 @@
package ru.stenfredd.movies
import android.content.Context
import android.content.Intent
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import org.json.JSONArray
import org.json.JSONObject
import ru.stenfredd.movies.core.data.ImageUrls
import ru.stenfredd.movies.core.data.SessionCache
import ru.stenfredd.movies.core.data.WatchRepository
import ru.stenfredd.movies.core.data.model.ItemKind
import ru.stenfredd.movies.core.data.model.ItemSummary
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
import ru.stenfredd.movies.ui.model.landscape
import ru.stenfredd.movies.ui.model.remaining
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.roundToInt
/**
* "Продолжить просмотр" on the TV's own home screen (the `ru.stenfredd.tvhome` shell): the app sends the shell the
* ready list whenever it changes, and the shell keeps and shows it without waking this app on every Home press.
*
* Contract v1: `call(content://ru.stenfredd.tvhome.feed, "continue_watching", <JSON array of cards>)`; the shell
* accepts it only from an app signed with its own key. An empty array hides the row. No shell on the device (another
* TV, an emulator) → nothing is computed or sent.
*
* Recomputed once per process start, when the account changes (sign-in, profile switch, sign-out) and when watch
* state changes ([SessionCache.invalidations]: playback stopped or left with Home, marked watched or favourite).
* Progress made on other devices shows up only at the next start: no periodic background work on this TV.
*/
@Singleton
class LauncherFeed @Inject constructor(
@ApplicationContext private val context: Context,
private val session: JellyfinSession,
private val cache: SessionCache,
private val watch: WatchRepository,
private val images: ImageUrls,
) {
/** What the shell has from this process; it is sent again after a restart in case the shell lost it. */
private var lastSent: String? = null
/** Runs for the life of the process; call off the main thread. */
@OptIn(FlowPreview::class)
suspend fun run() {
session.restored.first { it }
// Home loads the same lists at start: after it they come from the cache, and the start isn't slowed down.
delay(START_DELAY_MS)
merge(session.session.map { it?.accountId }.distinctUntilChanged(), cache.invalidations)
.debounce(DEBOUNCE_MS)
.collectLatest { publish() }
}
@Suppress("DEPRECATION") // the flags overload is API 33+
private suspend fun publish() {
if (context.packageManager.resolveContentProvider(AUTHORITY, 0) == null) return
val account = session.session.value?.accountId
val items = if (account == null) {
emptyList()
} else {
try {
// The home screen's own call (same cache entries), so usually no request at all.
watch.continueWatching().take(MAX_ITEMS)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// The shell keeps the previous list: better than an empty row.
Timber.w(e, "Launcher feed: not updated")
return
}
}
if (session.session.value?.accountId != account) return
val json = JSONArray(items.map(::card)).toString()
if (json == lastSent) return
try {
context.contentResolver.call(Uri.parse("content://$AUTHORITY"), METHOD, json, null)
lastSent = json
Timber.i("Launcher feed: sent %d cards", items.size)
} catch (e: IllegalArgumentException) {
Timber.d("Launcher feed: no shell (%s)", e.message)
} catch (e: SecurityException) {
// The shell takes lists only from apps signed with its key: sign both with the same release key.
Timber.w(e, "Launcher feed: refused by the shell")
}
}
private fun card(item: ItemSummary): JSONObject {
val started = item.user.progress != null
return JSONObject()
.put("id", item.id.hex())
.put("title", title(item))
.putOpt("subtitle", if (started) item.remaining() else if (item.kind == ItemKind.Episode) "следующая серия" else null)
.putOpt("progress", item.user.progress?.let { (it * 1000).roundToInt() / 1000.0 })
.putOpt("image", item.landscape()?.let { direct(images.url(it, IMAGE_W, IMAGE_H)) })
.put("intent", intent(item).toUri(Intent.URI_INTENT_SCHEME))
}
/** One line of ~28 characters: "Сериал · S2 E5" or the movie's name (an episode's name doesn't fit). */
private fun title(item: ItemSummary): String {
val series = item.seriesName?.takeIf { item.kind == ItemKind.Episode } ?: return item.name
val number = listOfNotNull(item.seasonNumber?.let { "S$it" }, item.episodeNumber?.let { "E$it" }).joinToString(" ")
return if (number.isEmpty()) series else "$series · $number"
}
/**
* Plays the item from where it was left (a next episode from the start), with the item's page under the player:
* a movie's page, an episode's series page on that episode. See `MainActivity.intentTarget`.
*/
private fun intent(item: ItemSummary): Intent = Intent()
.setClassName(context.packageName, MainActivity::class.java.name)
.putExtra("item", item.id.hex())
.putExtra("play", true)
.apply {
if (item.kind == ItemKind.Episode) item.seriesId?.let { putExtra("series", it.hex()) }
if (item.user.positionMs > 0) putExtra("start", item.user.positionMs)
}
/**
* The shell downloads covers itself, without the app's routing (`ServerRouting`): at home give it the LAN
* address the app uses, else the public one. The tag stays in the URL, so a changed image is a new URL.
*/
private fun direct(url: String): String {
val local = session.localUrl ?: return url
val public = session.session.value?.serverUrl?.trimEnd('/') ?: return url
return if (url.startsWith(public)) local + url.removePrefix(public) else url
}
/** Jellyfin's own form of ids: no dashes. */
private fun UUID.hex() = toString().replace("-", "")
private companion object {
const val AUTHORITY = "ru.stenfredd.tvhome.feed"
const val METHOD = "continue_watching"
/** The shell keeps up to 20 and shows 5. */
const val MAX_ITEMS = 10
/** The shell's card image, px. */
const val IMAGE_W = 326
const val IMAGE_H = 184
const val START_DELAY_MS = 3_000L
const val DEBOUNCE_MS = 1_000L
}
}

View File

@@ -1,5 +1,6 @@
package ru.stenfredd.movies package ru.stenfredd.movies
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -28,6 +29,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import kotlinx.coroutines.channels.Channel
import ru.stenfredd.movies.core.data.LibraryRepository import ru.stenfredd.movies.core.data.LibraryRepository
import ru.stenfredd.movies.core.data.model.LibraryType import ru.stenfredd.movies.core.data.model.LibraryType
import ru.stenfredd.movies.core.jellyfin.JellyfinSession import ru.stenfredd.movies.core.jellyfin.JellyfinSession
@@ -48,6 +50,7 @@ import ru.stenfredd.movies.ui.settings.SettingsScreen
import ru.stenfredd.movies.ui.player.PlayerScreen import ru.stenfredd.movies.ui.player.PlayerScreen
import ru.stenfredd.movies.ui.series.SeriesScreen import ru.stenfredd.movies.ui.series.SeriesScreen
import ru.stenfredd.movies.ui.login.LoginScreen import ru.stenfredd.movies.ui.login.LoginScreen
import ru.stenfredd.movies.ui.menu.WatchMenuHost
import ru.stenfredd.movies.ui.components.AppIcon import ru.stenfredd.movies.ui.components.AppIcon
import ru.stenfredd.movies.ui.shell.RailLibrary import ru.stenfredd.movies.ui.shell.RailLibrary
import ru.stenfredd.movies.ui.shell.Shell import ru.stenfredd.movies.ui.shell.Shell
@@ -67,6 +70,9 @@ class MainActivity : ComponentActivity() {
/** Account → its libraries on the rail, see [Root]. */ /** Account → its libraries on the rail, see [Root]. */
private var railLibraries: Pair<String, List<RailLibrary>> = "" to emptyList() private var railLibraries: Pair<String, List<RailLibrary>> = "" to emptyList()
/** Launches while the app is already open (a card on the TV's home screen), see [onNewIntent]. */
private val launches = Channel<Intent>(Channel.CONFLATED)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
@@ -75,15 +81,41 @@ class MainActivity : ComponentActivity() {
var ready by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val loggedIn = session.restore() val loggedIn = session.restore()
// One that came during the restore is already [getIntent], read just below.
launches.tryReceive()
stack += if (loggedIn) root(Tab.Home) else Screen.Login stack += if (loggedIn) root(Tab.Home) else Screen.Login
if (loggedIn) intentTarget()?.let(stack::addAll) if (loggedIn) intentTarget(intent)?.let(stack::addAll)
ready = true ready = true
} }
if (ready) Navigation(stack) if (ready) {
// Long press on a card (or ✓ on the series page): mark watched / unwatched, over any screen.
WatchMenuHost { Navigation(stack) }
LaunchedEffect(Unit) {
for (launch in launches) {
// Signed out: the login screen stays, as at a cold start. No target (the app's icon): stay where the app was.
if (session.session.value == null) continue
val target = intentTarget(launch) ?: continue
// As at a cold start, so Back leads to the app's home. An open player is released and saves its position.
stack.clear()
stack += root(Tab.Home)
stack += target
}
}
}
} }
} }
} }
/**
* The activity is `singleTask`: a launch from outside while it exists (the TV's home screen starts cards with
* `FLAG_ACTIVITY_NEW_TASK`) comes here instead of only bringing the old task with its old screen to the front.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
launches.trySend(intent)
}
@Composable @Composable
private fun Navigation(stack: MutableList<Screen>) { private fun Navigation(stack: MutableList<Screen>) {
fun open(screen: Screen) { fun open(screen: Screen) {
@@ -200,15 +232,16 @@ class MainActivity : ComponentActivity() {
* Opens an item from the launch intent: * Opens an item from the launch intent:
* `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item <id> [--ez play true] [--el start <ms>] * `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item <id> [--ez play true] [--el start <ms>]
* [--ei audio <index>] [--ei sub <index>]` (stream indexes as Jellyfin numbers them; with one given, the other is off), * [--ei audio <index>] [--ei sub <index>]` (stream indexes as Jellyfin numbers them; with one given, the other is off),
* or a series page: `--es series <id> [--es episode <id>]`. * or a series page: `--es series <id> [--es episode <id>]`. With both `item` (an episode) and `series`, the page
* under the player is the series page on that episode, as from the app's own cards (the TV's home screen does so).
* Debug builds also take `--es engine exo|vlc` (implies play) and `--ez tour true [--ei tour_file N]` for the debug tour. * Debug builds also take `--es engine exo|vlc` (implies play) and `--ez tour true [--ei tour_file N]` for the debug tour.
*/ */
private fun intentTarget(): List<Screen>? { private fun intentTarget(intent: Intent): List<Screen>? {
if (BuildConfig.DEBUG && intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1)) if (BuildConfig.DEBUG && intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1))
intent.getStringExtra("series")?.let(::parseId)?.let { series -> val series = intent.getStringExtra("series")?.let(::parseId)
return listOf(Screen.Series(series, intent.getStringExtra("episode")?.let(::parseId))) val id = intent.getStringExtra("item")?.let(::parseId)
} if (series != null && id == null) return listOf(Screen.Series(series, intent.getStringExtra("episode")?.let(::parseId)))
val id = intent.getStringExtra("item")?.let(::parseId) ?: return null if (id == null) return null
val engine = if (BuildConfig.DEBUG) intent.getStringExtra("engine")?.let(::EngineId) else null val engine = if (BuildConfig.DEBUG) intent.getStringExtra("engine")?.let(::EngineId) else null
val play = engine != null || intent.getBooleanExtra("play", false) val play = engine != null || intent.getBooleanExtra("play", false)
val streams = if (intent.hasExtra("audio") || intent.hasExtra("sub")) { val streams = if (intent.hasExtra("audio") || intent.hasExtra("sub")) {
@@ -216,7 +249,7 @@ class MainActivity : ComponentActivity() {
} else { } else {
null null
} }
return listOf(Screen.Detail(id)) + return listOf(series?.let { Screen.Series(it, id) } ?: Screen.Detail(id)) +
if (play) listOf(Screen.Player(id, engine, intent.getLongExtra("start", 0), streams = streams)) else emptyList() if (play) listOf(Screen.Player(id, engine, intent.getLongExtra("start", 0), streams = streams)) else emptyList()
} }

View File

@@ -10,7 +10,12 @@ import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565 import coil3.request.allowRgb565
import coil3.request.crossfade import coil3.request.crossfade
import dagger.Lazy
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -19,10 +24,13 @@ import javax.inject.Inject
class MoviesApp : Application(), SingletonImageLoader.Factory { class MoviesApp : Application(), SingletonImageLoader.Factory {
/** The API's client: images go to the server's LAN address at home too (`ServerRouting`), sharing its connections. */ /** The API's client: images go to the server's LAN address at home too (`ServerRouting`), sharing its connections. */
@Inject lateinit var httpClient: OkHttpClient @Inject lateinit var httpClient: OkHttpClient
@Inject lateinit var launcherFeed: Lazy<LauncherFeed>
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
// Built and run off the main thread: the first screen must not wait for it.
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { launcherFeed.get().run() }
} }
/** /**

View File

@@ -2,7 +2,6 @@ package ru.stenfredd.movies.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -28,6 +27,8 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ru.stenfredd.movies.ui.menu.WatchTarget
import ru.stenfredd.movies.ui.menu.clickableWithMenu
import ru.stenfredd.movies.ui.theme.AppColors import ru.stenfredd.movies.ui.theme.AppColors
import ru.stenfredd.movies.ui.theme.AppDimens import ru.stenfredd.movies.ui.theme.AppDimens
import ru.stenfredd.movies.ui.theme.AppType import ru.stenfredd.movies.ui.theme.AppType
@@ -47,6 +48,8 @@ data class PosterModel(
val watched: Boolean = false, val watched: Boolean = false,
/** Heart in the corner (favourites); shown instead of the watched mark. */ /** Heart in the corner (favourites); shown instead of the watched mark. */
val favorite: Boolean = false, val favorite: Boolean = false,
/** What a long press marks watched or unwatched. */
val watch: WatchTarget? = null,
) )
/** Episode still or backdrop with a title over it: "Continue watching" cards. */ /** Episode still or backdrop with a title over it: "Continue watching" cards. */
@@ -57,6 +60,7 @@ data class WideModel(
val subtitle: String, val subtitle: String,
val image: RemoteImageSpec?, val image: RemoteImageSpec?,
val progress: Float?, val progress: Float?,
val watch: WatchTarget? = null,
) )
private val PosterCorner = 7.dp private val PosterCorner = 7.dp
@@ -73,7 +77,7 @@ fun PosterCard(model: PosterModel, onClick: () -> Unit, modifier: Modifier = Mod
Modifier Modifier
.focusCard(FocusStyle.Poster) .focusCard(FocusStyle.Poster)
.size(AppDimens.POSTER_W.dp, AppDimens.POSTER_H.dp) .size(AppDimens.POSTER_W.dp, AppDimens.POSTER_H.dp)
.clickable(interactionSource = null, indication = null, onClick = onClick), .clickableWithMenu(model.watch, onClick),
) { ) {
RemoteImage(model.image, PosterCorner, Modifier.fillMaxSize()) RemoteImage(model.image, PosterCorner, Modifier.fillMaxSize())
if (model.isNew) NewBadge(Modifier.align(Alignment.TopStart).padding(6.dp)) if (model.isNew) NewBadge(Modifier.align(Alignment.TopStart).padding(6.dp))
@@ -120,7 +124,7 @@ fun WideCard(model: WideModel, onClick: () -> Unit, modifier: Modifier = Modifie
modifier modifier
.focusCard(FocusStyle.Wide) .focusCard(FocusStyle.Wide)
.size(AppDimens.WIDE_W.dp, AppDimens.WIDE_H.dp) .size(AppDimens.WIDE_W.dp, AppDimens.WIDE_H.dp)
.clickable(interactionSource = null, indication = null, onClick = onClick), .clickableWithMenu(model.watch, onClick),
) { ) {
RemoteImage(model.image, WideCorner, Modifier.fillMaxSize(), shade = WideShade) RemoteImage(model.image, WideCorner, Modifier.fillMaxSize(), shade = WideShade)
Column(Modifier.align(Alignment.BottomStart).padding(start = 10.dp, end = 10.dp, bottom = 11.dp)) { Column(Modifier.align(Alignment.BottomStart).padding(start = 10.dp, end = 10.dp, bottom = 11.dp)) {
@@ -150,6 +154,7 @@ data class FeaturedModel(
val image: RemoteImageSpec?, val image: RemoteImageSpec?,
val progress: Float, val progress: Float,
val remaining: String?, val remaining: String?,
val watch: WatchTarget? = null,
) )
@Composable @Composable
@@ -158,7 +163,7 @@ fun FeaturedCard(model: FeaturedModel, onClick: () -> Unit, modifier: Modifier =
modifier modifier
.focusCard(FocusStyle.Featured) .focusCard(FocusStyle.Featured)
.size(410.dp, 230.5.dp) .size(410.dp, 230.5.dp)
.clickable(interactionSource = null, indication = null, onClick = onClick), .clickableWithMenu(model.watch, onClick),
) { ) {
RemoteImage(model.image, FeaturedCorner, Modifier.fillMaxSize(), shade = FeaturedShade) RemoteImage(model.image, FeaturedCorner, Modifier.fillMaxSize(), shade = FeaturedShade)
Column(Modifier.align(Alignment.BottomStart).padding(start = 20.dp, end = 20.dp, bottom = 22.dp)) { Column(Modifier.align(Alignment.BottomStart).padding(start = 20.dp, end = 20.dp, bottom = 22.dp)) {

View File

@@ -0,0 +1,348 @@
package ru.stenfredd.movies.ui.menu
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.nativeKeyCode
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import ru.stenfredd.movies.core.data.WatchRepository
import ru.stenfredd.movies.core.data.model.ItemKind
import ru.stenfredd.movies.ui.components.AppIcon
import ru.stenfredd.movies.ui.components.FocusStyle
import ru.stenfredd.movies.ui.components.Icon
import ru.stenfredd.movies.ui.components.focusCard
import ru.stenfredd.movies.ui.model.plural
import ru.stenfredd.movies.ui.player.SidePanel
import ru.stenfredd.movies.ui.theme.AppColors
import ru.stenfredd.movies.ui.theme.AppType
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.math.roundToInt
/** What the watched menu marks: a movie, an episode, or every episode of a season or a series. */
@Immutable
data class WatchTarget(
val id: UUID,
val kind: ItemKind,
/** The movie or series name. */
val title: String,
/** Which part of the series: "S1 · E4 · Пилот", "Сезон 2"; the year of a movie. */
val subtitle: String? = null,
/** Watched; of a season or series — every episode. */
val played: Boolean,
/** A started movie or episode, 0..1. */
val progress: Float? = null,
/** Episodes of a season or series and how many are watched: known on the series page. */
val episodes: Int? = null,
val watchedEpisodes: Int? = null,
/** Some episode of a season or series is started (has a place to continue from). */
val started: Boolean = false,
/** Unwatched episodes of a season or series, from a card (the server doesn't tell watched ones there). */
val unplayed: Int? = null,
) {
val isGroup: Boolean get() = kind == ItemKind.Series || kind == ItemKind.Season
}
/** Opens the watched menu; provided by [WatchMenuHost] above every screen. */
@Stable
class WatchMenu internal constructor() {
internal var shown: Shown? by mutableStateOf(null)
/** Shows the menu for [target]; focus goes back to [returnTo] when it closes. */
fun open(target: WatchTarget, returnTo: FocusRequester) {
shown = Shown(target, returnTo)
}
/** One opening of the menu (a class: a reply that comes late must not close the next one). */
internal class Shown(val target: WatchTarget, val returnTo: FocusRequester)
}
val LocalWatchMenu = staticCompositionLocalOf<WatchMenu?> { null }
/**
* Click of a card or chip that also opens the watched menu for [target]: OK held down (a long press), or the remote's
* Menu key where it has one. Without a target it's a plain click. Put it where `clickable` would go.
*/
@Composable
fun Modifier.clickableWithMenu(target: WatchTarget?, onClick: () -> Unit): Modifier {
val menu = LocalWatchMenu.current
if (target == null || menu == null) return clickable(interactionSource = null, indication = null, onClick = onClick)
val self = remember { FocusRequester() }
val open = remember(menu, target) { { menu.open(target, self) } }
return this
.focusRequester(self)
.onKeyEvent { e ->
(e.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_MENU).also { if (it && e.type == KeyEventType.KeyUp) open() }
}
.combinedClickable(interactionSource = null, indication = null, onLongClick = open, onClick = onClick)
}
@HiltViewModel
class WatchMenuViewModel @Inject constructor(private val watch: WatchRepository) : ViewModel() {
/** Screens reload on their own when it's done (the cache tells them the user data changed). */
suspend fun setPlayed(id: UUID, played: Boolean) = watch.setPlayed(id, played)
}
/**
* Hosts the watched menu over [content]: a panel on the right, opaque (a full-screen scrim is expensive on weak TV
* GPUs), as the player's settings panels. Marking a season or series marks every episode on the server.
*/
@Composable
fun WatchMenuHost(content: @Composable () -> Unit) {
val menu = remember { WatchMenu() }
val vm: WatchMenuViewModel = hiltViewModel()
// Not the panel's scope: a request goes on when the panel is closed before the server answers.
val scope = rememberCoroutineScope()
fun close(shown: WatchMenu.Shown) {
if (menu.shown !== shown) return
// Focus goes back before the panel goes: a focused panel leaving would hand focus to the page's first button.
val back = runCatching { shown.returnTo.requestFocus() }.getOrDefault(false)
menu.shown = null
if (!back) {
scope.launch {
withFrameNanos { }
runCatching { shown.returnTo.requestFocus() }
}
}
}
CompositionLocalProvider(LocalWatchMenu provides menu) {
Box(Modifier.fillMaxSize()) {
content()
menu.shown?.let { shown ->
key(shown) {
var saving by remember { mutableStateOf<Boolean?>(null) }
var error by remember { mutableStateOf<String?>(null) }
WatchPanel(
shown.target,
saving = saving,
error = error,
onPick = { played ->
if (saving == null) {
saving = played
error = null
scope.launch {
try {
vm.setPlayed(shown.target.id, played)
close(shown)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Watched menu: marking failed")
saving = null
error = e.message ?: e.toString()
}
}
}
},
onClose = { close(shown) },
modifier = Modifier.align(Alignment.CenterEnd),
)
}
}
}
}
}
private class MenuAction(val played: Boolean, val icon: AppIcon, val label: String, val detail: String?)
private val ActionFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))), animate = false)
/**
* The menu itself: what the item is, how much of it is watched, and the actions. [saving] is the action being sent
* (true = watched), [error] why the last one failed.
*/
@Composable
private fun WatchPanel(
target: WatchTarget,
saving: Boolean?,
error: String?,
onPick: (Boolean) -> Unit,
onClose: () -> Unit,
modifier: Modifier,
) {
val initial = remember { FocusRequester() }
// The long press that opened the menu may still be held: its repeats and release must not press an action,
// so OK counts only from a fresh press.
val okArmed = remember { booleanArrayOf(false) }
// Back acts on release, as Android does: closing on the press would send the release to the page, which would leave it.
val backDown = remember { booleanArrayOf(false) }
SidePanel(
title = target.kindLabel(),
dot = AppColors.Green,
hint = "Назад — закрыть",
initial = initial,
modifier = modifier.onPreviewKeyEvent {
val native = it.nativeKeyEvent
when (native.keyCode) {
AndroidKeyEvent.KEYCODE_DPAD_CENTER, AndroidKeyEvent.KEYCODE_ENTER, AndroidKeyEvent.KEYCODE_NUMPAD_ENTER -> {
if (it.type == KeyEventType.KeyDown && native.repeatCount == 0) okArmed[0] = true
!okArmed[0]
}
AndroidKeyEvent.KEYCODE_BACK, AndroidKeyEvent.KEYCODE_ESCAPE -> {
when (it.type) {
KeyEventType.KeyDown -> backDown[0] = true
KeyEventType.KeyUp -> if (backDown[0]) onClose()
}
true
}
else -> false
}
},
) {
Column {
BasicText(target.title, style = AppType.Section.copy(fontSize = 17.sp, lineHeight = 21.sp), maxLines = 2, overflow = TextOverflow.Ellipsis)
target.subtitle?.let {
BasicText(it, Modifier.padding(top = 5.dp), style = AppType.MonoMeta, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
target.status()?.let {
BasicText(it, Modifier.padding(top = 9.dp), style = AppType.Body.copy(color = if (target.played) AppColors.Green else AppColors.TextMuted))
}
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
target.actions().forEachIndexed { i, action ->
ActionRow(
action,
saving = saving == action.played,
onClick = { onPick(action.played) },
modifier = if (i == 0) Modifier.focusRequester(initial) else Modifier,
)
}
}
error?.let {
BasicText("Не удалось сохранить: $it", style = AppType.Body.copy(color = AppColors.Red), maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
}
/** "✓ Отметить просмотренным" with what it touches under it. */
@Composable
private fun ActionRow(action: MenuAction, saving: Boolean, onClick: () -> Unit, modifier: Modifier) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(7.dp)
val tint = if (action.played) AppColors.Green else AppColors.TextSecondary
Row(
modifier
.focusCard(ActionFocus)
.fillMaxWidth()
.height(if (action.detail != null) 52.dp else 42.dp)
.background(if (focused) Color(0xFF1F2238) else AppColors.SurfaceRaised, shape)
.border(0.5.dp, if (focused) AppColors.Accent else Color(0xFF232637), shape)
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(11.dp),
) {
Icon(action.icon, tint, Modifier.size(15.dp))
Column(Modifier.weight(1f)) {
BasicText(
action.label,
style = AppType.BodyLarge.copy(fontWeight = FontWeight.Medium, color = AppColors.Text),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
(if (saving) "Сохраняем…" else action.detail)?.let {
BasicText(it, Modifier.padding(top = 4.dp), style = AppType.MonoCaption.copy(fontSize = 9.5.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
private fun WatchTarget.kindLabel(): String = when (kind) {
ItemKind.Episode -> "СЕРИЯ"
ItemKind.Season -> "СЕЗОН"
ItemKind.Series -> "СЕРИАЛ"
else -> "ФИЛЬМ"
}
private fun episodesWord(n: Int) = plural(n, "серия", "серии", "серий")
/** "Просмотрено 12 из 62 серий", "Просмотрено 42%", "Не просмотрен". */
private fun WatchTarget.status(): String? {
val feminine = kind == ItemKind.Episode
if (!isGroup) {
return when {
played -> if (feminine) "Просмотрена" else "Просмотрен"
progress != null -> "Просмотрено ${(progress * 100).roundToInt()}%"
else -> if (feminine) "Не просмотрена" else "Не просмотрен"
}
}
return when {
played -> "Все серии просмотрены"
episodes != null && watchedEpisodes != null -> when (watchedEpisodes) {
0 -> "Ни одной серии не просмотрено"
else -> "Просмотрено $watchedEpisodes из $episodes ${plural(episodes, "серии", "серий", "серий")}"
}
unplayed != null && unplayed > 0 -> "Не просмотрено: $unplayed ${episodesWord(unplayed)}"
else -> null
}
}
/**
* Only what changes something: "watched" unless it already is, "unwatched" if anything is watched or started.
* A card doesn't tell whether some episodes of a series are watched, so it offers both.
*/
private fun WatchTarget.actions(): List<MenuAction> = buildList {
if (!isGroup) {
val ending = if (kind == ItemKind.Episode) "ой" else "ым"
if (!played) add(MenuAction(true, AppIcon.Check, "Отметить просмотренн$ending", null))
if (played || progress != null) {
add(MenuAction(false, AppIcon.Replay, "Отметить непросмотренн$ending", if (progress != null) "Место остановки сбросится" else null))
}
return@buildList
}
val noun = if (kind == ItemKind.Season) "сезон" else "сериал"
if (!played) {
add(MenuAction(true, AppIcon.Check, "Отметить весь $noun просмотренным", episodes?.let { "$it ${episodesWord(it)}" } ?: "Все серии"))
}
if (played || started || watchedEpisodes == null || watchedEpisodes > 0) {
add(MenuAction(false, AppIcon.Replay, "Отметить весь $noun непросмотренным", "Отметки и места остановки всех серий сбросятся"))
}
}

View File

@@ -11,6 +11,7 @@ import ru.stenfredd.movies.ui.components.FeaturedModel
import ru.stenfredd.movies.ui.components.PosterModel import ru.stenfredd.movies.ui.components.PosterModel
import ru.stenfredd.movies.ui.components.RemoteImageSpec import ru.stenfredd.movies.ui.components.RemoteImageSpec
import ru.stenfredd.movies.ui.components.WideModel import ru.stenfredd.movies.ui.components.WideModel
import ru.stenfredd.movies.ui.menu.WatchTarget
import ru.stenfredd.movies.ui.theme.AppDimens import ru.stenfredd.movies.ui.theme.AppDimens
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -45,6 +46,7 @@ class CardMapper @Inject constructor(
progress = item.user.progress, progress = item.user.progress,
isNew = isNew(item), isNew = isNew(item),
watched = item.user.played, watched = item.user.played,
watch = watchTarget(item),
) )
} }
@@ -66,6 +68,7 @@ class CardMapper @Inject constructor(
subtitle = listOfNotNull(item.episodeLabel() ?: item.year?.toString(), item.remaining()).joinToString(" · "), subtitle = listOfNotNull(item.episodeLabel() ?: item.year?.toString(), item.remaining()).joinToString(" · "),
image = image(item.landscape(), AppDimens.WIDE_W.toFloat(), AppDimens.WIDE_H.toFloat()), image = image(item.landscape(), AppDimens.WIDE_W.toFloat(), AppDimens.WIDE_H.toFloat()),
progress = item.user.progress, progress = item.user.progress,
watch = watchTarget(item),
) )
fun featured(item: ItemSummary, row: String): FeaturedModel { fun featured(item: ItemSummary, row: String): FeaturedModel {
@@ -83,6 +86,7 @@ class CardMapper @Inject constructor(
image = image(item.backdrop ?: item.landscape(), FEATURED_W, FEATURED_H), image = image(item.backdrop ?: item.landscape(), FEATURED_W, FEATURED_H),
progress = item.user.progress ?: 0f, progress = item.user.progress ?: 0f,
remaining = item.remaining(), remaining = item.remaining(),
watch = watchTarget(item),
) )
} }
@@ -98,18 +102,34 @@ class CardMapper @Inject constructor(
else -> Screen.Detail(item.id) else -> Screen.Detail(item.id)
} }
/** What the card's long press marks watched: the item itself (a single new episode too, though it shows its series). */
fun watchTarget(item: ItemSummary): WatchTarget? {
val part = item.kind == ItemKind.Episode || item.kind == ItemKind.Season
return when (item.kind) {
ItemKind.Movie, ItemKind.Video, ItemKind.Episode, ItemKind.Season, ItemKind.Series -> WatchTarget(
id = item.id,
kind = item.kind,
title = (if (part) item.seriesName else null) ?: item.name,
subtitle = when (item.kind) {
ItemKind.Episode -> listOfNotNull(
item.seasonNumber?.let { "S$it" },
item.episodeNumber?.let { "E$it" },
item.name.takeIf { item.seriesName != null },
).joinToString(" · ").ifEmpty { null }
ItemKind.Season -> item.name.takeIf { item.seriesName != null }
else -> item.year?.toString()
},
played = item.user.played,
progress = item.user.progress,
unplayed = item.user.unplayedCount.takeIf { item.kind == ItemKind.Series || item.kind == ItemKind.Season },
)
else -> null
}
}
fun image(ref: ImageRef?, wDp: Float, hDp: Float): RemoteImageSpec? = fun image(ref: ImageRef?, wDp: Float, hDp: Float): RemoteImageSpec? =
ref?.let { RemoteImageSpec(images.url(it, px(wDp), px(hDp)), it.blurHash) } ref?.let { RemoteImageSpec(images.url(it, px(wDp), px(hDp)), it.blurHash) }
/** Episode still, else the backdrop or thumb of the item or its series. */
private fun ItemSummary.landscape(): ImageRef? = (if (kind == ItemKind.Episode) primary else null) ?: backdrop ?: thumb
private fun ItemSummary.remaining(): String? {
val runtime = runtimeMs ?: return null
val left = runtime * (1 - (user.progress ?: 0f)) / 60_000
return "осталось ${left.roundToInt()} мин"
}
private fun ItemSummary.episodeLabel(): String? = private fun ItemSummary.episodeLabel(): String? =
if (kind == ItemKind.Episode && seasonNumber != null && episodeNumber != null) "S$seasonNumber · E$episodeNumber · $name" else null if (kind == ItemKind.Episode && seasonNumber != null && episodeNumber != null) "S$seasonNumber · E$episodeNumber · $name" else null
@@ -133,3 +153,13 @@ class CardMapper @Inject constructor(
const val FEATURED_H = 230.5f const val FEATURED_H = 230.5f
} }
} }
/** Episode still, else the backdrop or thumb of the item or its series. Also the TV home screen's cards (`LauncherFeed`). */
fun ItemSummary.landscape(): ImageRef? = (if (kind == ItemKind.Episode) primary else null) ?: backdrop ?: thumb
/** "осталось 31 мин" from the item's progress. */
fun ItemSummary.remaining(): String? {
val runtime = runtimeMs ?: return null
val left = runtime * (1 - (user.progress ?: 0f)) / 60_000
return "осталось ${left.roundToInt()} мин"
}

View File

@@ -41,7 +41,7 @@ import ru.stenfredd.movies.ui.components.focusCard
import ru.stenfredd.movies.ui.theme.AppColors import ru.stenfredd.movies.ui.theme.AppColors
import ru.stenfredd.movies.ui.theme.AppType import ru.stenfredd.movies.ui.theme.AppType
/** The card shows this long before the end of an episode that has a next one. */ /** Without known credits the card shows this long before the end of an episode that has a next one. */
internal const val NEXT_PROMPT_MS = 30_000L internal const val NEXT_PROMPT_MS = 30_000L
private val CardColor = Color(0xFF0E1018) private val CardColor = Color(0xFF0E1018)
@@ -51,12 +51,13 @@ private val PromptFocus = FocusStyle(scale = 1f, corner = 17.dp, glows = FocusSt
/** /**
* "Следующая через N с" at the end of an episode: still and title of the next one, "Смотреть сейчас" and * "Следующая через N с" at the end of an episode: still and title of the next one, "Смотреть сейчас" and
* "Досмотреть титры". Opaque (over video a translucent card costs a blend every frame); the countdown and the bar * "Досмотреть титры". Opaque (over video a translucent card costs a blend every frame); the countdown and the bar
* move once a second, as the position does. * move once a second, as the position does. [remainingMs] of [countdownMs] are left until the next episode starts.
*/ */
@Composable @Composable
internal fun NextEpisodeCard( internal fun NextEpisodeCard(
next: NextEpisode, next: NextEpisode,
remainingMs: Long, remainingMs: Long,
countdownMs: Long,
playNow: FocusRequester, playNow: FocusRequester,
onPlay: () -> Unit, onPlay: () -> Unit,
onStay: () -> Unit, onStay: () -> Unit,
@@ -65,7 +66,7 @@ internal fun NextEpisodeCard(
) { ) {
val shape = RoundedCornerShape(10.dp) val shape = RoundedCornerShape(10.dp)
val seconds = ((remainingMs + 999) / 1000).coerceAtLeast(0) val seconds = ((remainingMs + 999) / 1000).coerceAtLeast(0)
val fraction = (1f - remainingMs.toFloat() / NEXT_PROMPT_MS).coerceIn(0f, 1f) val fraction = (1f - remainingMs.toFloat() / countdownMs).coerceIn(0f, 1f)
Column( Column(
modifier modifier
.width(320.dp) .width(320.dp)

View File

@@ -39,6 +39,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import ru.stenfredd.movies.core.data.model.MediaSegment
import ru.stenfredd.movies.ui.components.AppIcon import ru.stenfredd.movies.ui.components.AppIcon
import kotlin.math.roundToInt import kotlin.math.roundToInt
import ru.stenfredd.movies.ui.components.FocusStyle import ru.stenfredd.movies.ui.components.FocusStyle
@@ -131,11 +132,19 @@ internal fun OsdTop(label: String, title: String, subtitle: String?, endsAt: Str
} }
/** /**
* Track with the buffered part, the played part and chapter marks; thicker with a knob when focused. * Track with the buffered part, skippable parts (intro, credits), the played part and chapter marks; thicker with a
* Drawn as plain rectangles in one draw call: no layers, no paths. * knob when focused. Drawn as plain rectangles in one draw call: no layers, no paths.
*/ */
@Composable @Composable
internal fun Timeline(positionMs: Long, durationMs: Long, bufferedMs: Long, chaptersMs: List<Long>, focused: Boolean, modifier: Modifier = Modifier) { internal fun Timeline(
positionMs: Long,
durationMs: Long,
bufferedMs: Long,
chaptersMs: List<Long>,
spans: List<MediaSegment>,
focused: Boolean,
modifier: Modifier = Modifier,
) {
Spacer( Spacer(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
@@ -148,6 +157,10 @@ internal fun Timeline(positionMs: Long, durationMs: Long, bufferedMs: Long, chap
fun x(ms: Long) = if (durationMs > 0) (ms.toFloat() / durationMs).coerceIn(0f, 1f) * w else 0f fun x(ms: Long) = if (durationMs > 0) (ms.toFloat() / durationMs).coerceIn(0f, 1f) * w else 0f
drawRoundRect(Color(0x29FFFFFF), Offset(0f, top), Size(w, trackH), r) drawRoundRect(Color(0x29FFFFFF), Offset(0f, top), Size(w, trackH), r)
if (bufferedMs > positionMs) drawRoundRect(Color(0x2EFFFFFF), Offset(0f, top), Size(x(bufferedMs), trackH), r) if (bufferedMs > positionMs) drawRoundRect(Color(0x2EFFFFFF), Offset(0f, top), Size(x(bufferedMs), trackH), r)
for (span in spans) {
val from = x(span.startMs)
drawRect(SpanColor, Offset(from, top), Size(x(span.endMs) - from, trackH))
}
val played = x(positionMs) val played = x(positionMs)
if (played > 0f) { if (played > 0f) {
drawRoundRect( drawRoundRect(
@@ -171,6 +184,8 @@ internal fun Timeline(positionMs: Long, durationMs: Long, bufferedMs: Long, chap
) )
} }
private val SpanColor = Color(0x598F6BFF)
/** ⟲ ⟳ ⏮ ⏭: translucent circle, light fill when focused. */ /** ⟲ ⟳ ⏮ ⏭: translucent circle, light fill when focused. */
@Composable @Composable
internal fun OsdCircleButton(icon: AppIcon, size: Dp, iconSize: Dp, onClick: () -> Unit, modifier: Modifier = Modifier) { internal fun OsdCircleButton(icon: AppIcon, size: Dp, iconSize: Dp, onClick: () -> Unit, modifier: Modifier = Modifier) {

View File

@@ -51,6 +51,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import ru.stenfredd.movies.BuildConfig import ru.stenfredd.movies.BuildConfig
import ru.stenfredd.movies.core.data.model.MediaInfo import ru.stenfredd.movies.core.data.model.MediaInfo
import ru.stenfredd.movies.core.data.model.MediaSegment
import ru.stenfredd.movies.core.data.model.VideoRange import ru.stenfredd.movies.core.data.model.VideoRange
import ru.stenfredd.movies.core.data.toMediaInfo import ru.stenfredd.movies.core.data.toMediaInfo
import ru.stenfredd.movies.playback.api.DolbyVisionSwitchable import ru.stenfredd.movies.playback.api.DolbyVisionSwitchable
@@ -77,6 +78,8 @@ import java.util.UUID
* ↓ opens the drawer ("О фильме" / episodes), ↑ shows the controls. Seeks are collected while the keys are pressed * ↓ opens the drawer ("О фильме" / episodes), ↑ shows the controls. Seeks are collected while the keys are pressed
* and sent to the engine once they stop. The controls hide after 6 s of no keys while playing. * and sent to the engine once they stop. The controls hide after 6 s of no keys while playing.
* Back: panel → drawer → controls → leave the player. * Back: panel → drawer → controls → leave the player.
* Segments from the server (intro, credits …) get "Пропустить …" or are skipped on their own, as set in the settings;
* known credits bring the "next episode" card at their start.
*/ */
@Composable @Composable
fun PlayerScreen( fun PlayerScreen(
@@ -130,6 +133,21 @@ private class PlayerUiState {
var nextDismissedFor by mutableStateOf<UUID?>(null) var nextDismissedFor by mutableStateOf<UUID?>(null)
/** The "next episode" card has focus: arrows and OK go to its buttons. */ /** The "next episode" card has focus: arrows and OK go to its buttons. */
var promptFocused = false var promptFocused = false
/** Where the "next episode" card appeared over known credits: its countdown runs from there. */
var nextFrom by mutableStateOf<Long?>(null)
/** Segments skipped in this playback (by the button or on their own): not offered again. */
var skipped by mutableStateOf(SegmentSet())
/** Segments the user seeked into: they went there on purpose, so these are not skipped on their own. */
var seekedInto by mutableStateOf(SegmentSet())
/** Segments whose button timed out over the bare video: it shows only with the controls. */
var skipHidden by mutableStateOf(SegmentSet())
/** The skip button has focus: OK goes to it. */
var skipFocused = false
/** Something in the player has focus (a disappearing button can leave focus nowhere). */
var hasFocus = false
/** "Заставка пропущена" after an automatic skip. */
var notice by mutableStateOf<String?>(null)
var noticeVersion by mutableIntStateOf(0)
val root = FocusRequester() val root = FocusRequester()
val playPause = FocusRequester() val playPause = FocusRequester()
@@ -138,6 +156,7 @@ private class PlayerUiState {
val drawerInitial = FocusRequester() val drawerInitial = FocusRequester()
val errorButton = FocusRequester() val errorButton = FocusRequester()
val nextPlay = FocusRequester() val nextPlay = FocusRequester()
val skipButton = FocusRequester()
val debugMenuFocus = FocusRequester() val debugMenuFocus = FocusRequester()
val chips = PlayerPanel.entries.associateWith { FocusRequester() } val chips = PlayerPanel.entries.associateWith { FocusRequester() }
var panelFrom: FocusRequester? = null var panelFrom: FocusRequester? = null
@@ -171,6 +190,8 @@ private fun PlayerUi(
val info by vm.info.collectAsStateWithLifecycle() val info by vm.info.collectAsStateWithLifecycle()
val season by vm.season.collectAsStateWithLifecycle() val season by vm.season.collectAsStateWithLifecycle()
val style by vm.subtitleStyle.collectAsStateWithLifecycle() val style by vm.subtitleStyle.collectAsStateWithLifecycle()
val segments by vm.segments.collectAsStateWithLifecycle()
val defaults by vm.defaults.collectAsStateWithLifecycle()
val ui = remember { PlayerUiState() } val ui = remember { PlayerUiState() }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val media = remember(state.source) { state.source?.toMediaInfo() } val media = remember(state.source) { state.source?.toMediaInfo() }
@@ -179,10 +200,30 @@ private fun PlayerUi(
val ended = state.engineState.phase == EngineState.Phase.ENDED val ended = state.engineState.phase == EngineState.Phase.ENDED
val shownPosition = ui.scrubTarget ?: state.positionMs val shownPosition = ui.scrubTarget ?: state.positionMs
val remainingMs = state.durationMs - state.positionMs val remainingMs = state.durationMs - state.positionMs
// "Следующая через N с" near the end of an episode, unless dismissed or something is open over the video. val itemId = state.itemId
val next = season?.nextEpisode?.takeIf { val nextEpisode = season?.takeIf { it.currentId == itemId && info?.isEpisode == true }?.nextEpisode
info?.isEpisode == true && state.durationMs > 0 && remainingMs in 0..NEXT_PROMPT_MS && !ended && state.error == null && val plan = remember(segments, itemId, state.durationMs, defaults, nextEpisode != null) {
ui.nextDismissedFor != state.itemId && ui.panel == null && !ui.drawer segments?.takeIf { it.itemId == itemId }?.let { SkipPlan.of(it.segments, state.durationMs, defaults, hasNext = nextEpisode != null) }
}
val credits = plan?.credits
val overVideo = !ended && state.error == null && ui.panel == null && !ui.drawer
val nextDismissed = ui.nextDismissedFor == itemId
// Known credits with "Сразу": the next episode starts with them, unless the user seeked into them.
val creditsAuto = credits != null && plan?.creditsAuto == true && credits !in ui.seekedInto.of(itemId) && !nextDismissed
// "Следующая через N с" when the credits start (or near the end), unless dismissed or something is open over the video.
val nextWindow = if (credits != null) shownPosition >= credits.startMs else remainingMs in 0..NEXT_PROMPT_MS
val next = nextEpisode?.takeIf { state.durationMs > 0 && remainingMs >= 0 && nextWindow && overVideo && !nextDismissed && !creditsAuto }
// Its countdown: to the end of the file, or over known credits from where it appeared.
val nextCountdownMs = if (credits != null) CREDITS_COUNTDOWN_MS else NEXT_PROMPT_MS
val nextLeftMs = if (credits != null) minOf((ui.nextFrom ?: state.positionMs) + CREDITS_COUNTDOWN_MS - state.positionMs, remainingMs) else remainingMs
// "Пропустить заставку" while in a skippable part. A part skipped on its own shows the button only when seeked into.
val skip = plan?.skipAt(shownPosition)?.takeIf { s ->
overVideo && !state.loading && next == null && s.segment !in ui.skipped.of(itemId) &&
(!s.auto || s.segment in ui.seekedInto.of(itemId)) && (ui.osd || s.segment !in ui.skipHidden.of(itemId))
}
val autoSkip = plan?.skipAt(state.positionMs)?.takeIf { s ->
s.auto && ui.scrubTarget == null && !state.loading && state.error == null && !ended &&
s.segment !in ui.skipped.of(itemId) && s.segment !in ui.seekedInto.of(itemId)
} }
fun hideOsd() { fun hideOsd() {
@@ -192,6 +233,15 @@ private fun PlayerUi(
if (next != null) ui.focus(ui.nextPlay) if (next != null) ui.focus(ui.nextPlay)
} }
fun skipPart(part: Skippable, auto: Boolean) {
ui.skipped = ui.skipped.plus(itemId, part.segment)
session.seekTo(part.segment.endMs)
if (auto) {
ui.notice = SkipLabels.skipped(part.segment.kind)
ui.noticeVersion++
}
}
fun showOsd(focus: FocusRequester) { fun showOsd(focus: FocusRequester) {
ui.osd = true ui.osd = true
ui.focus(focus) ui.focus(focus)
@@ -243,6 +293,7 @@ private fun PlayerUi(
ui.nextDismissedFor = state.itemId ui.nextDismissedFor = state.itemId
ui.focus(ui.root) ui.focus(ui.root)
} }
skip != null -> ui.skipHidden = ui.skipHidden.plus(itemId, skip.segment)
else -> onExit() else -> onExit()
} }
} }
@@ -277,7 +328,7 @@ private fun PlayerUi(
if (!focusManager.moveFocus(FocusDirection.Up) && repeat == 0) hideOsd() if (!focusManager.moveFocus(FocusDirection.Up) && repeat == 0) hideOsd()
} }
AndroidKeyEvent.KEYCODE_DPAD_UP if !ui.osd && repeat > 0 && tour == null && next == null -> Unit AndroidKeyEvent.KEYCODE_DPAD_UP if !ui.osd && repeat > 0 && tour == null && next == null -> Unit
else -> return onNavigationKey(code, repeat, ui, session, tour, ::scrub, ::showOsd, ::openDrawer, promptShown = next != null) else -> return onNavigationKey(code, repeat, ui, session, tour, ::scrub, ::showOsd, ::openDrawer, promptShown = next != null, skipShown = skip != null)
} }
return true return true
} }
@@ -297,6 +348,7 @@ private fun PlayerUi(
LaunchedEffect(ui.scrubVersion) { LaunchedEffect(ui.scrubVersion) {
val target = ui.scrubTarget ?: return@LaunchedEffect val target = ui.scrubTarget ?: return@LaunchedEffect
delay(SCRUB_COMMIT_MS) delay(SCRUB_COMMIT_MS)
plan?.spanAt(target)?.let { ui.seekedInto = ui.seekedInto.plus(state.itemId, it) }
session.seekTo(target) session.seekTo(target)
// Keep showing the target until the engine reports the new position. // Keep showing the target until the engine reports the new position.
delay(SCRUB_SETTLE_MS) delay(SCRUB_SETTLE_MS)
@@ -325,13 +377,45 @@ private fun PlayerUi(
LaunchedEffect(state.itemId) { LaunchedEffect(state.itemId) {
if (ui.drawer) closeDrawer() if (ui.drawer) closeDrawer()
} }
LaunchedEffect(autoSkip) {
autoSkip?.let { skipPart(it, auto = true) }
}
val creditsGo = creditsAuto && nextEpisode != null && credits != null && state.positionMs >= credits.startMs &&
ui.scrubTarget == null && overVideo && !state.loading
val nextCountedDown = next != null && credits != null && nextLeftMs <= 0
LaunchedEffect(creditsGo, nextCountedDown) {
if (creditsGo || nextCountedDown) nextEpisode?.let { vm.playEpisode(it.id) }
}
LaunchedEffect(ui.noticeVersion) {
if (ui.notice == null) return@LaunchedEffect
delay(SKIP_NOTICE_MS)
ui.notice = null
}
// Over the bare video the skip button takes focus and goes after a while (counted without the controls shown).
LaunchedEffect(skip?.segment, ui.osd) {
val part = skip?.segment ?: return@LaunchedEffect
if (ui.osd) return@LaunchedEffect
ui.focus(ui.skipButton)
delay(SKIP_PROMPT_MS)
ui.skipHidden = ui.skipHidden.plus(itemId, part)
}
val skipShown = skip != null
LaunchedEffect(skipShown) {
if (skipShown) return@LaunchedEffect
withFrameNanos { }
if (ui.skipFocused || !ui.hasFocus) {
ui.skipFocused = false
if (next == null) ui.focus(if (ui.osd) ui.playPause else ui.root)
}
}
// The card takes focus when it appears over the bare video; with the controls shown they keep it. // The card takes focus when it appears over the bare video; with the controls shown they keep it.
val nextShown = next != null val nextShown = next != null
LaunchedEffect(nextShown) { LaunchedEffect(nextShown) {
if (nextShown && !ui.osd) ui.focus(ui.nextPlay) if (nextShown && !ui.osd) ui.focus(ui.nextPlay)
if (!nextShown) ui.promptFocused = false if (!nextShown) ui.promptFocused = false
ui.nextFrom = if (nextShown) state.positionMs else null
} }
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { session.pause() } LifecycleEventEffect(Lifecycle.Event.ON_STOP) { session.pauseInBackground() }
// Back while nothing in the player has focus (the key handler above doesn't see it then). // Back while nothing in the player has focus (the key handler above doesn't see it then).
BackHandler { back() } BackHandler { back() }
@@ -341,6 +425,7 @@ private fun PlayerUi(
.fillMaxSize() .fillMaxSize()
.focusRequester(ui.root) .focusRequester(ui.root)
.onPreviewKeyEvent(::onKey) .onPreviewKeyEvent(::onKey)
.onFocusChanged { ui.hasFocus = it.hasFocus }
.focusable(), .focusable(),
) { ) {
if (!ui.osd && !playing && !state.loading && state.error == null && !ended) PausedBadge(Modifier.align(Alignment.Center)) if (!ui.osd && !playing && !state.loading && state.error == null && !ended) PausedBadge(Modifier.align(Alignment.Center))
@@ -352,6 +437,7 @@ private fun PlayerUi(
media = media, media = media,
ui = ui, ui = ui,
positionMs = shownPosition, positionMs = shownPosition,
spans = plan?.spans.orEmpty(),
onToggle = session::togglePlayPause, onToggle = session::togglePlayPause,
onSeek = { scrub(it, 0) }, onSeek = { scrub(it, 0) },
onEpisode = vm::playEpisode, onEpisode = vm::playEpisode,
@@ -387,10 +473,29 @@ private fun PlayerUi(
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
) )
} }
// Prompts sit in the corner, above the controls while they are shown.
val promptBottom = if (ui.osd) PROMPT_OVER_OSD.dp else 32.dp
skip?.let { part ->
SkipButton(
label = SkipLabels.button(part.segment.kind),
left = PlayerLabels.time(part.segment.endMs - shownPosition),
focusRequester = ui.skipButton,
onClick = {
skipPart(part, auto = false)
ui.focus(if (ui.osd) ui.playPause else ui.root)
},
onFocus = { ui.skipFocused = it },
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = promptBottom),
)
}
ui.notice?.takeIf { skip == null && next == null }?.let { text ->
SkipNotice(text, Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = promptBottom))
}
next?.let { n -> next?.let { n ->
NextEpisodeCard( NextEpisodeCard(
n, n,
remainingMs = remainingMs, remainingMs = nextLeftMs,
countdownMs = nextCountdownMs,
playNow = ui.nextPlay, playNow = ui.nextPlay,
onPlay = { onPlay = {
hideOsd() hideOsd()
@@ -402,8 +507,7 @@ private fun PlayerUi(
ui.focus(if (ui.osd) ui.playPause else ui.root) ui.focus(if (ui.osd) ui.playPause else ui.root)
}, },
onFocus = { ui.promptFocused = it }, onFocus = { ui.promptFocused = it },
// Above the controls while they are shown. modifier = Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = promptBottom),
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = if (ui.osd) 132.dp else 32.dp),
) )
} }
if (state.loading && state.error == null) LoadingBadge(Modifier.align(Alignment.Center)) if (state.loading && state.error == null) LoadingBadge(Modifier.align(Alignment.Center))
@@ -443,6 +547,7 @@ private fun onNavigationKey(
showOsd: (FocusRequester) -> Unit, showOsd: (FocusRequester) -> Unit,
openDrawer: () -> Unit, openDrawer: () -> Unit,
promptShown: Boolean, promptShown: Boolean,
skipShown: Boolean,
): Boolean { ): Boolean {
if (BuildConfig.DEBUG && !ui.debugMenu) { if (BuildConfig.DEBUG && !ui.debugMenu) {
when (code) { when (code) {
@@ -460,6 +565,8 @@ private fun onNavigationKey(
if (ui.panel != null || ui.drawer || ui.debugMenu) return false if (ui.panel != null || ui.drawer || ui.debugMenu) return false
// The "next episode" card over the bare video: ←/→ and OK work its buttons. // The "next episode" card over the bare video: ←/→ and OK work its buttons.
if (!ui.osd && promptShown && ui.promptFocused && code in PROMPT_KEYS) return false if (!ui.osd && promptShown && ui.promptFocused && code in PROMPT_KEYS) return false
// The skip button over the bare video: OK presses it, the arrows work as usual.
if (!ui.osd && skipShown && ui.skipFocused && code in OK_KEYS) return false
if (!ui.osd) { if (!ui.osd) {
when (code) { when (code) {
AndroidKeyEvent.KEYCODE_DPAD_UP if tour != null -> { AndroidKeyEvent.KEYCODE_DPAD_UP if tour != null -> {
@@ -505,6 +612,7 @@ private fun OsdLayer(
media: MediaInfo?, media: MediaInfo?,
ui: PlayerUiState, ui: PlayerUiState,
positionMs: Long, positionMs: Long,
spans: List<MediaSegment>,
onToggle: () -> Unit, onToggle: () -> Unit,
onSeek: (Int) -> Unit, onSeek: (Int) -> Unit,
onEpisode: (UUID) -> Unit, onEpisode: (UUID) -> Unit,
@@ -542,6 +650,7 @@ private fun OsdLayer(
durationMs = state.durationMs, durationMs = state.durationMs,
bufferedMs = state.bufferedMs, bufferedMs = state.bufferedMs,
chaptersMs = info?.chaptersMs.orEmpty(), chaptersMs = info?.chaptersMs.orEmpty(),
spans = spans,
focused = ui.timelineFocused, focused = ui.timelineFocused,
modifier = Modifier modifier = Modifier
.padding(top = 3.dp) .padding(top = 3.dp)
@@ -706,15 +815,13 @@ private fun PanelLayer(
private val GRAPHIC_SUBTITLES = setOf("pgssub", "hdmv_pgs_subtitle", "dvdsub", "dvd_subtitle", "vobsub", "dvbsub") private val GRAPHIC_SUBTITLES = setOf("pgssub", "hdmv_pgs_subtitle", "dvdsub", "dvd_subtitle", "vobsub", "dvbsub")
private val PROMPT_KEYS = setOf( private val OK_KEYS = setOf(AndroidKeyEvent.KEYCODE_DPAD_CENTER, AndroidKeyEvent.KEYCODE_ENTER, AndroidKeyEvent.KEYCODE_NUMPAD_ENTER)
AndroidKeyEvent.KEYCODE_DPAD_LEFT,
AndroidKeyEvent.KEYCODE_DPAD_RIGHT, private val PROMPT_KEYS = OK_KEYS + setOf(AndroidKeyEvent.KEYCODE_DPAD_LEFT, AndroidKeyEvent.KEYCODE_DPAD_RIGHT)
AndroidKeyEvent.KEYCODE_DPAD_CENTER,
AndroidKeyEvent.KEYCODE_ENTER,
AndroidKeyEvent.KEYCODE_NUMPAD_ENTER,
)
private const val OSD_TIMEOUT_MS = 6_000L private const val OSD_TIMEOUT_MS = 6_000L
/** Bottom of the skip button and the "next episode" card while the controls are shown: clear of the time labels. */
private const val PROMPT_OVER_OSD = 150
private const val TOP_SHADE = 105 private const val TOP_SHADE = 105
private const val BOTTOM_SHADE = 170 private const val BOTTOM_SHADE = 170
private const val SCRUB_COMMIT_MS = 600L private const val SCRUB_COMMIT_MS = 600L

View File

@@ -0,0 +1,176 @@
package ru.stenfredd.movies.ui.player
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ru.stenfredd.movies.core.data.model.MediaSegment
import ru.stenfredd.movies.core.data.model.SegmentKind
import ru.stenfredd.movies.playback.core.PlaybackDefaults
import ru.stenfredd.movies.playback.core.SkipMode
import ru.stenfredd.movies.ui.components.FocusStyle
import ru.stenfredd.movies.ui.components.focusCard
import ru.stenfredd.movies.ui.theme.AppColors
import ru.stenfredd.movies.ui.theme.AppType
import java.util.UUID
/** The server's segments of one item (they arrive after the item starts playing). */
@Immutable
data class PlayerSegments(val itemId: UUID, val segments: List<MediaSegment>)
/** A segment the player offers to skip ("Пропустить заставку") or skips on its own ([auto]). */
@Immutable
internal data class Skippable(val segment: MediaSegment, val auto: Boolean)
/**
* What the player does with an item's segments under the settings.
* [credits]: the credits of an episode with a next one, from their start to the end of the file: the "next episode"
* card shows from their start instead of 30 s before the end ([creditsAuto]: the next episode starts right away).
*/
@Immutable
internal data class SkipPlan(val skips: List<Skippable>, val credits: MediaSegment?, val creditsAuto: Boolean) {
/** Parts drawn on the timeline. */
val spans: List<MediaSegment> = skips.map { it.segment } + listOfNotNull(credits)
/** The skippable part [positionMs] is in (not counting its last second: nothing left to skip there). */
fun skipAt(positionMs: Long): Skippable? = skips.firstOrNull { positionMs >= it.segment.startMs && positionMs < it.segment.endMs - SKIP_END_MARGIN_MS }
/** The part a seek to [positionMs] lands in: such a part is not skipped on its own, the user went there. */
fun spanAt(positionMs: Long): MediaSegment? = spans.firstOrNull { positionMs >= it.startMs && positionMs < it.endMs }
companion object {
/**
* Credits (and previews after them) that run to the end of the file count as the end of the episode: with a
* next episode they bring the "next" card, without one (a movie, the last episode) there is nothing to skip to.
* Credits followed by more of the episode (an epilogue) get "Пропустить титры".
*/
fun of(segments: List<MediaSegment>, durationMs: Long, defaults: PlaybackDefaults, hasNext: Boolean): SkipPlan? {
if (durationMs <= 0) return null
val useful = segments.filter { it.endMs - it.startMs >= MIN_SEGMENT_MS && it.startMs < durationMs }
var tail: MediaSegment? = null
for (s in useful.filter { it.kind == SegmentKind.Outro || it.kind == SegmentKind.Preview }.sortedByDescending { it.startMs }) {
val reaches = tail?.let { s.endMs >= it.startMs - TAIL_GAP_MS } ?: (s.endMs >= durationMs - TAIL_MARGIN_MS)
if (!reaches) break
tail = MediaSegment(SegmentKind.Outro, s.startMs, durationMs)
}
val tailStart = tail?.startMs ?: Long.MAX_VALUE
val skips = useful.filter { it.startMs < tailStart }.mapNotNull { s ->
val mode = if (s.kind == SegmentKind.Outro) defaults.skipCredits else defaults.skipIntro
if (mode == SkipMode.Off) null else Skippable(s, auto = mode == SkipMode.Auto)
}
val credits = tail?.takeIf { hasNext && defaults.skipCredits != SkipMode.Off }
return SkipPlan(skips, credits, creditsAuto = defaults.skipCredits == SkipMode.Auto)
}
}
}
/** Segments of one item something already happened to; an item change forgets them. */
@Immutable
internal data class SegmentSet(val itemId: UUID? = null, val segments: Set<MediaSegment> = emptySet()) {
fun of(id: UUID?): Set<MediaSegment> = if (id == itemId) segments else emptySet()
fun plus(id: UUID?, segment: MediaSegment): SegmentSet = SegmentSet(id, of(id) + segment)
}
internal object SkipLabels {
fun button(kind: SegmentKind): String = when (kind) {
SegmentKind.Intro -> "Пропустить заставку"
SegmentKind.Recap -> "Пропустить пересказ"
SegmentKind.Preview -> "Пропустить анонс"
SegmentKind.Outro -> "Пропустить титры"
SegmentKind.Commercial -> "Пропустить рекламу"
}
fun skipped(kind: SegmentKind): String = when (kind) {
SegmentKind.Intro -> "Заставка пропущена"
SegmentKind.Recap -> "Пересказ пропущен"
SegmentKind.Preview -> "Анонс пропущен"
SegmentKind.Outro -> "Титры пропущены"
SegmentKind.Commercial -> "Реклама пропущена"
}
}
private val SkipColor = Color(0xFF10121C)
private val SkipBorder = Color(0x40FFFFFF)
/** `0 0 0 3px #a594ff, 0 0 40px 8px rgba(124,92,255,.65)` */
private val SkipFocus = FocusStyle(scale = 1f, corner = 20.dp, glows = listOf(FocusStyle.Glow(20.dp, 4.dp, Color(0xA67C5CFF))), animate = false)
/**
* "Пропустить заставку 1:12" (time left of the part) in the corner where the "next episode" card goes. Opaque, as
* that card: a translucent one over video costs a blend every frame.
*/
@Composable
internal fun SkipButton(label: String, left: String, focusRequester: FocusRequester, onClick: () -> Unit, onFocus: (Boolean) -> Unit, modifier: Modifier = Modifier) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(20.dp)
Row(
modifier
.focusRequester(focusRequester)
.focusCard(SkipFocus)
.height(40.dp)
.background(if (focused) AppColors.FocusFill else SkipColor, shape)
.border(0.5.dp, if (focused) AppColors.FocusFill else SkipBorder, shape)
.onFocusChanged {
focused = it.isFocused
onFocus(it.isFocused)
}
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 17.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val color = if (focused) AppColors.OnFocusFill else Color.White
BasicText(label, style = AppType.Button.copy(fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = color))
BasicText(left, style = AppType.MonoMeta.copy(fontSize = 10.sp, color = color.copy(alpha = 0.7f)))
}
}
/** "Заставка пропущена" for a few seconds after an automatic skip; not focusable. */
@Composable
internal fun SkipNotice(text: String, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(16.dp)
BasicText(
text,
modifier
.background(SkipColor, shape)
.border(0.5.dp, SkipBorder, shape)
.padding(horizontal = 14.dp, vertical = 8.dp),
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary),
)
}
/** The button over the bare video hides after this long; it comes back with the controls. */
internal const val SKIP_PROMPT_MS = 10_000L
/** With known credits the "next episode" card counts down this long from their start. */
internal const val CREDITS_COUNTDOWN_MS = 15_000L
internal const val SKIP_NOTICE_MS = 3_000L
private const val SKIP_END_MARGIN_MS = 1_000L
/** Shorter segments are detection noise. */
private const val MIN_SEGMENT_MS = 2_000L
/** Credits ending this close to the end of the file run to the end (black frames after them, rounding). */
private const val TAIL_MARGIN_MS = 10_000L
/** Credits and a preview this far apart still make one tail. */
private const val TAIL_GAP_MS = 2_000L

View File

@@ -24,6 +24,7 @@ import ru.stenfredd.movies.playback.api.StreamSelection
import ru.stenfredd.movies.playback.api.SubtitleStyle import ru.stenfredd.movies.playback.api.SubtitleStyle
import ru.stenfredd.movies.playback.core.DeviceCapabilitiesProbe import ru.stenfredd.movies.playback.core.DeviceCapabilitiesProbe
import ru.stenfredd.movies.playback.core.EngineSelector import ru.stenfredd.movies.playback.core.EngineSelector
import ru.stenfredd.movies.playback.core.PlaybackDefaults
import ru.stenfredd.movies.playback.core.PlaybackPreferences import ru.stenfredd.movies.playback.core.PlaybackPreferences
import ru.stenfredd.movies.playback.core.PlaybackSession import ru.stenfredd.movies.playback.core.PlaybackSession
import ru.stenfredd.movies.playback.core.PlaybackSessionFactory import ru.stenfredd.movies.playback.core.PlaybackSessionFactory
@@ -94,6 +95,8 @@ data class PlayerInfo(
/** The season of the episode being played, for the drawer and ⏮/⏭. */ /** The season of the episode being played, for the drawer and ⏮/⏭. */
@Immutable @Immutable
data class SeasonEpisodes( data class SeasonEpisodes(
/** The episode being played: until the next one's season loads, this still describes the previous one. */
val currentId: UUID,
/** "СЕЗОН 2 · СЕРИИ" */ /** "СЕЗОН 2 · СЕРИИ" */
val title: String, val title: String,
/** "10 серий · 3 просмотрено" */ /** "10 серий · 3 просмотрено" */
@@ -122,10 +125,18 @@ class PlayerViewModel @javax.inject.Inject constructor(
private val _season = MutableStateFlow<SeasonEpisodes?>(null) private val _season = MutableStateFlow<SeasonEpisodes?>(null)
val season: StateFlow<SeasonEpisodes?> = _season.asStateFlow() val season: StateFlow<SeasonEpisodes?> = _season.asStateFlow()
private val _segments = MutableStateFlow<PlayerSegments?>(null)
/** Intro, credits …: skip buttons and the "next episode" card. */
val segments: StateFlow<PlayerSegments?> = _segments.asStateFlow()
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle()) val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
/** For the skip settings. */
val defaults: StateFlow<PlaybackDefaults> = prefs.defaults.stateIn(viewModelScope, SharingStarted.Eagerly, PlaybackDefaults())
private var startedFor: Screen.Player? = null private var startedFor: Screen.Player? = null
private var infoJob: Job? = null private var infoJob: Job? = null
private var segmentsJob: Job? = null
/** Quality chosen in the player: kept for the next episode. Until then the default from the settings applies. */ /** Quality chosen in the player: kept for the next episode. Until then the default from the settings applies. */
private var maxBitrate: Int? = null private var maxBitrate: Int? = null
private var qualityChosen = false private var qualityChosen = false
@@ -179,6 +190,7 @@ class PlayerViewModel @javax.inject.Inject constructor(
override fun onCleared() = release() override fun onCleared() = release()
private fun loadInfo(itemId: UUID) { private fun loadInfo(itemId: UUID) {
loadSegments(itemId)
infoJob?.cancel() infoJob?.cancel()
if (_info.value?.id != itemId) _info.value = null if (_info.value?.id != itemId) _info.value = null
infoJob = viewModelScope.launch { infoJob = viewModelScope.launch {
@@ -202,11 +214,27 @@ class PlayerViewModel @javax.inject.Inject constructor(
} }
} }
/** Apart from the info: a server without segments (or failing to give them) must not cost the drawer. */
private fun loadSegments(itemId: UUID) {
segmentsJob?.cancel()
if (_segments.value?.itemId != itemId) _segments.value = null
segmentsJob = viewModelScope.launch {
try {
_segments.value = PlayerSegments(itemId, library.segments(itemId))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Player: segments failed")
}
}
}
private fun season(current: ItemSummary, all: List<ItemSummary>): SeasonEpisodes { private fun season(current: ItemSummary, all: List<ItemSummary>): SeasonEpisodes {
val index = all.indexOfFirst { it.id == current.id } val index = all.indexOfFirst { it.id == current.id }
val inSeason = all.filter { it.seasonId == current.seasonId } val inSeason = all.filter { it.seasonId == current.seasonId }
val watched = inSeason.count { it.user.played } val watched = inSeason.count { it.user.played }
return SeasonEpisodes( return SeasonEpisodes(
currentId = current.id,
title = listOfNotNull(current.seasonNumber?.let { "СЕЗОН $it" }, "СЕРИИ").joinToString(" · "), title = listOfNotNull(current.seasonNumber?.let { "СЕЗОН $it" }, "СЕРИИ").joinToString(" · "),
stat = "${inSeason.size} ${plural(inSeason.size, "серия", "серии", "серий")} · $watched просмотрено", stat = "${inSeason.size} ${plural(inSeason.size, "серия", "серии", "серий")} · $watched просмотрено",
episodes = inSeason.map { e -> episodes = inSeason.map { e ->

View File

@@ -84,6 +84,8 @@ import ru.stenfredd.movies.ui.focus.Restore
import ru.stenfredd.movies.ui.focus.ScreenFocus import ru.stenfredd.movies.ui.focus.ScreenFocus
import ru.stenfredd.movies.ui.focus.rememberScreenFocus import ru.stenfredd.movies.ui.focus.rememberScreenFocus
import ru.stenfredd.movies.ui.focus.remembered import ru.stenfredd.movies.ui.focus.remembered
import ru.stenfredd.movies.ui.menu.LocalWatchMenu
import ru.stenfredd.movies.ui.menu.clickableWithMenu
import ru.stenfredd.movies.ui.theme.AppColors import ru.stenfredd.movies.ui.theme.AppColors
import ru.stenfredd.movies.ui.theme.AppDimens import ru.stenfredd.movies.ui.theme.AppDimens
import ru.stenfredd.movies.ui.theme.AppType import ru.stenfredd.movies.ui.theme.AppType
@@ -118,6 +120,7 @@ fun SeriesScreen(
private const val KEY_PLAY = "play" private const val KEY_PLAY = "play"
private const val KEY_TRACKS = "tracks" private const val KEY_TRACKS = "tracks"
private const val KEY_WATCHED = "watched"
/** /**
* What the page highlights: the season of the chips and the map, the episode described at the bottom. * What the page highlights: the season of the chips and the map, the episode described at the bottom.
@@ -288,6 +291,15 @@ private fun Header(
Modifier.remembered(focus, "favorite"), Modifier.remembered(focus, "favorite"),
tint = if (item.favorite) AppColors.Pink else Color(0xFFE3E5F2), tint = if (item.favorite) AppColors.Pink else Color(0xFFE3E5F2),
) )
// The whole series at once, so through the menu (it says how many episodes) rather than a toggle.
val menu = LocalWatchMenu.current
val watchedButton = remember { FocusRequester() }
CircleButton(
AppIcon.Check,
{ menu?.open(item.watch, watchedButton) },
Modifier.focusRequester(watchedButton).remembered(focus, KEY_WATCHED),
tint = if (item.watch.played) AppColors.Green else Color(0xFFE3E5F2),
)
} }
} }
} }
@@ -514,7 +526,7 @@ private fun SeasonChip(season: SeriesSeason, selected: Boolean, onClick: () -> U
.background(if (focused) AppColors.FocusFill else if (selected) Color(0xFF211D4A) else AppColors.SurfaceRaised, shape) .background(if (focused) AppColors.FocusFill else if (selected) Color(0xFF211D4A) else AppColors.SurfaceRaised, shape)
.border(0.5.dp, if (focused) AppColors.FocusFill else if (selected) Color(0xFF7A6CFF) else AppColors.Border, shape) .border(0.5.dp, if (focused) AppColors.FocusFill else if (selected) Color(0xFF7A6CFF) else AppColors.Border, shape)
.onFocusChanged { focused = it.isFocused } .onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick) .clickableWithMenu(season.watch, onClick)
.padding(horizontal = 12.dp), .padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -594,7 +606,7 @@ private fun EpisodeCard(e: SeriesEpisode, onClick: () -> Unit, modifier: Modifie
modifier modifier
.focusCard(EpisodeFocus) .focusCard(EpisodeFocus)
.size(SeriesLayout.EPISODE_W.dp, SeriesLayout.EPISODE_H.dp) .size(SeriesLayout.EPISODE_W.dp, SeriesLayout.EPISODE_H.dp)
.clickable(interactionSource = null, indication = null, onClick = onClick), .clickableWithMenu(e.watch, onClick),
) { ) {
RemoteImage(e.image, 8.dp, Modifier.fillMaxSize()) RemoteImage(e.image, 8.dp, Modifier.fillMaxSize())
BasicText( BasicText(

View File

@@ -25,6 +25,7 @@ import ru.stenfredd.movies.core.data.LibraryRepository
import ru.stenfredd.movies.core.data.SessionCache import ru.stenfredd.movies.core.data.SessionCache
import ru.stenfredd.movies.core.data.WatchRepository import ru.stenfredd.movies.core.data.WatchRepository
import ru.stenfredd.movies.core.data.model.ItemDetails import ru.stenfredd.movies.core.data.model.ItemDetails
import ru.stenfredd.movies.core.data.model.ItemKind
import ru.stenfredd.movies.core.data.model.ItemSummary import ru.stenfredd.movies.core.data.model.ItemSummary
import ru.stenfredd.movies.core.data.model.MediaInfo import ru.stenfredd.movies.core.data.model.MediaInfo
import ru.stenfredd.movies.playback.api.SubtitleStyle import ru.stenfredd.movies.playback.api.SubtitleStyle
@@ -34,6 +35,7 @@ import ru.stenfredd.movies.ui.components.RemoteImageSpec
import ru.stenfredd.movies.ui.details.MediaLabels import ru.stenfredd.movies.ui.details.MediaLabels
import ru.stenfredd.movies.ui.details.TrackChoices import ru.stenfredd.movies.ui.details.TrackChoices
import ru.stenfredd.movies.ui.details.TrackOption import ru.stenfredd.movies.ui.details.TrackOption
import ru.stenfredd.movies.ui.menu.WatchTarget
import ru.stenfredd.movies.ui.model.CardMapper import ru.stenfredd.movies.ui.model.CardMapper
import ru.stenfredd.movies.ui.player.PlayerLabels import ru.stenfredd.movies.ui.player.PlayerLabels
import ru.stenfredd.movies.ui.model.plural import ru.stenfredd.movies.ui.model.plural
@@ -70,6 +72,8 @@ data class SeriesEpisode(
val progress: Float?, val progress: Float?,
val state: EpisodeState, val state: EpisodeState,
val resumeMs: Long, val resumeMs: Long,
/** What a long press on the card marks watched. */
val watch: WatchTarget,
) )
@Immutable @Immutable
@@ -88,6 +92,8 @@ data class SeriesSeason(
val target: Int, val target: Int,
/** Row index of the season's divider. */ /** Row index of the season's divider. */
val dividerIndex: Int, val dividerIndex: Int,
/** The whole season, for a long press on its chip. */
val watch: WatchTarget,
) )
@Immutable @Immutable
@@ -103,6 +109,8 @@ data class SeriesModel(
val poster: RemoteImageSpec?, val poster: RemoteImageSpec?,
val backdropUrl: String?, val backdropUrl: String?,
val favorite: Boolean, val favorite: Boolean,
/** The whole series, for the ✓ button; played when every episode is. */
val watch: WatchTarget,
val seasons: List<SeriesSeason>, val seasons: List<SeriesSeason>,
val episodes: List<SeriesEpisode>, val episodes: List<SeriesEpisode>,
/** Index of the episode "Продолжить"/"Смотреть" plays. */ /** Index of the episode "Продолжить"/"Смотреть" plays. */
@@ -255,14 +263,15 @@ class SeriesViewModel @AssistedInject constructor(
val first = episodes.size val first = episodes.size
val dividerIndex = row++ val dividerIndex = row++
list.forEachIndexed { i, e -> list.forEachIndexed { i, e ->
episodes += episode(e, si, row++, i, season, isNextUp = e === all.getOrNull(nextUp)) episodes += episode(e, si, row++, i, season, item.name, isNextUp = e === all.getOrNull(nextUp))
} }
val own = episodes.subList(first, episodes.size) val own = episodes.subList(first, episodes.size)
val watched = own.count { it.state == EpisodeState.Watched } val watched = own.count { it.state == EpisodeState.Watched }
val number = season.seasonNumber val number = season.seasonNumber
val label = if (number != null && number > 0) "Сезон $number" else season.name
seasons += SeriesSeason( seasons += SeriesSeason(
key = "season/${season.id}", key = "season/${season.id}",
label = if (number != null && number > 0) "Сезон $number" else season.name, label = label,
stat = when { stat = when {
watched == own.size -> "✓" watched == own.size -> "✓"
watched == 0 && own.all { it.state == EpisodeState.New } -> "новый" watched == 0 && own.all { it.state == EpisodeState.New } -> "новый"
@@ -277,6 +286,7 @@ class SeriesViewModel @AssistedInject constructor(
?: 0 ?: 0
), ),
dividerIndex = dividerIndex, dividerIndex = dividerIndex,
watch = groupTarget(season.id, ItemKind.Season, item.name, label, own),
) )
} }
@@ -297,6 +307,7 @@ class SeriesViewModel @AssistedInject constructor(
poster = cards.image(item.primary, SeriesLayout.POSTER_W, SeriesLayout.POSTER_H), poster = cards.image(item.primary, SeriesLayout.POSTER_W, SeriesLayout.POSTER_H),
backdropUrl = item.backdrop?.let { images.url(it, cards.px(SeriesLayout.BACKDROP_W), cards.px(SeriesLayout.BACKDROP_H), quality = 80) }, backdropUrl = item.backdrop?.let { images.url(it, cards.px(SeriesLayout.BACKDROP_W), cards.px(SeriesLayout.BACKDROP_H), quality = 80) },
favorite = item.user.favorite, favorite = item.user.favorite,
watch = groupTarget(item.id, ItemKind.Series, item.name, null, episodes),
seasons = seasons, seasons = seasons,
episodes = episodes, episodes = episodes,
nextUp = nextUp.coerceAtLeast(0), nextUp = nextUp.coerceAtLeast(0),
@@ -304,7 +315,7 @@ class SeriesViewModel @AssistedInject constructor(
) )
} }
private fun episode(e: ItemSummary, season: Int, rowIndex: Int, position: Int, seasonItem: ItemSummary, isNextUp: Boolean): SeriesEpisode { private fun episode(e: ItemSummary, season: Int, rowIndex: Int, position: Int, seasonItem: ItemSummary, seriesName: String, isNextUp: Boolean): SeriesEpisode {
val seasonNumber = e.seasonNumber ?: seasonItem.seasonNumber ?: 0 val seasonNumber = e.seasonNumber ?: seasonItem.seasonNumber ?: 0
// Some files have no episode number: their place in the season stands in. // Some files have no episode number: their place in the season stands in.
val number = e.episodeNumber ?: (position + 1) val number = e.episodeNumber ?: (position + 1)
@@ -335,6 +346,29 @@ class SeriesViewModel @AssistedInject constructor(
progress = progress, progress = progress,
state = state, state = state,
resumeMs = if (progress != null) e.user.positionMs else 0, resumeMs = if (progress != null) e.user.positionMs else 0,
watch = WatchTarget(
id = e.id,
kind = ItemKind.Episode,
title = seriesName,
subtitle = "S$seasonNumber · E${e.episodeNumber ?: number} · ${e.name}",
played = e.user.played,
progress = progress,
),
)
}
/** A season or the whole series: every episode of it, as the page counts them (specials too). */
private fun groupTarget(id: UUID, kind: ItemKind, title: String, subtitle: String?, episodes: List<SeriesEpisode>): WatchTarget {
val watched = episodes.count { it.state == EpisodeState.Watched }
return WatchTarget(
id = id,
kind = kind,
title = title,
subtitle = subtitle,
played = episodes.isNotEmpty() && watched == episodes.size,
episodes = episodes.size,
watchedEpisodes = watched,
started = episodes.any { it.state == EpisodeState.InProgress },
) )
} }

View File

@@ -60,6 +60,7 @@ import ru.stenfredd.movies.core.jellyfin.Account
import ru.stenfredd.movies.playback.api.SubtitleStyle import ru.stenfredd.movies.playback.api.SubtitleStyle
import ru.stenfredd.movies.playback.api.VideoPathSwitchable import ru.stenfredd.movies.playback.api.VideoPathSwitchable
import ru.stenfredd.movies.playback.core.PlaybackDefaults import ru.stenfredd.movies.playback.core.PlaybackDefaults
import ru.stenfredd.movies.playback.core.SkipMode
import ru.stenfredd.movies.ui.Screen import ru.stenfredd.movies.ui.Screen
import ru.stenfredd.movies.ui.components.Avatar import ru.stenfredd.movies.ui.components.Avatar
import ru.stenfredd.movies.ui.components.Chip import ru.stenfredd.movies.ui.components.Chip
@@ -296,8 +297,25 @@ private fun PlaybackSection(vm: SettingsViewModel, focus: ScreenFocus) {
Choices("dv", options, defaults.dolbyVision, focus) { set(defaults.copy(dolbyVision = it)) } Choices("dv", options, defaults.dolbyVision, focus) { set(defaults.copy(dolbyVision = it)) }
} }
} }
Setting(
"Заставки",
"Опенинги, пересказ прошлых серий, анонсы. «Кнопка» — в начале заставки появляется «Пропустить заставку», " +
"«Сразу» — плеер перескакивает её сам. Работает там, где сервер знает заставки (плагин Intro Skipper).",
) {
Choices("skip-intro", skipOptions, defaults.skipIntro, focus) { set(defaults.copy(skipIntro = it)) }
}
Setting(
"Титры",
"«Кнопка» — с началом титров появляется карточка «Следующая серия» с отсчётом (или «Пропустить титры», если " +
"после них ещё что-то есть). «Сразу» — следующая серия включается, как только начались титры. «Не пропускать» — " +
"карточка за 30 с до конца серии.",
) {
Choices("skip-credits", skipOptions, defaults.skipCredits, focus) { set(defaults.copy(skipCredits = it)) }
}
} }
private val skipOptions = listOf(Option(SkipMode.Button, "Кнопка"), Option(SkipMode.Auto, "Сразу"), Option(SkipMode.Off, "Не пропускать"))
// --- Языки --- // --- Языки ---
private val AudioLanguages = listOf("rus" to "Русский", "eng" to "Английский", "jpn" to "Японский") private val AudioLanguages = listOf("rus" to "Русский", "eng" to "Английский", "jpn" to "Японский")

View File

@@ -3,6 +3,7 @@ package ru.stenfredd.movies.core.data
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import org.jellyfin.sdk.api.client.extensions.libraryApi 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.personApi
import org.jellyfin.sdk.api.client.extensions.showApi import org.jellyfin.sdk.api.client.extensions.showApi
import org.jellyfin.sdk.api.client.extensions.userViewApi 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.ItemSummary
import ru.stenfredd.movies.core.data.model.LibraryType import ru.stenfredd.movies.core.data.model.LibraryType
import ru.stenfredd.movies.core.data.model.LibraryView 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.Page
import ru.stenfredd.movies.core.data.model.Person import ru.stenfredd.movies.core.data.model.Person
import ru.stenfredd.movies.core.data.model.itemKinds 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() 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) { 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() } 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.CollectionType
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields 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.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType 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.LibraryType
import ru.stenfredd.movies.core.data.model.LibraryView import ru.stenfredd.movies.core.data.model.LibraryView
import ru.stenfredd.movies.core.data.model.MediaInfo 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.Person
import ru.stenfredd.movies.core.data.model.PersonKind 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.SubtitleTrack
import ru.stenfredd.movies.core.data.model.UserState import ru.stenfredd.movies.core.data.model.UserState
import ru.stenfredd.movies.core.data.model.VideoRange 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) }, 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( internal fun BaseItemDto.toLibraryView(): LibraryView = LibraryView(
id = id, id = id,
name = name.orEmpty(), 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 * "Continue watching", one card per movie or series: started items first (most recent first; of a series only its
* that isn't already there. Of one library when [parentId] is given. * 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 { 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 nextUp = async { nextUp(limit, parentId) }
val started = resume.await() (resume.await() + nextUp.await()).distinctBy { it.seriesId ?: it.id }.take(limit)
val seriesStarted = started.mapNotNullTo(HashSet()) { it.seriesId }
(started + nextUp.await().filter { it.seriesId !in seriesStarted && started.none { s -> s.id == it.id } }).take(limit)
} }
/** Watched movies and episodes, most recent first; of one library when [parentId] is given. */ /** 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 { private companion object {
const val FAVORITES_LIMIT = 500 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) 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. */ /** The file of a playable item: what the format badges and the "file" line show, and the track choices. */
data class MediaInfo( data class MediaInfo(
val sourceId: String, val sourceId: String,

View File

@@ -65,6 +65,10 @@ class JellyfinSession @Inject constructor(
private val _session = MutableStateFlow<SessionInfo?>(null) private val _session = MutableStateFlow<SessionInfo?>(null)
val session: StateFlow<SessionInfo?> = _session.asStateFlow() 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()) private val _accounts = MutableStateFlow<List<Account>>(emptyList())
/** Saved accounts, most recently used first. */ /** Saved accounts, most recently used first. */
val accounts: StateFlow<List<Account>> = _accounts.asStateFlow() val accounts: StateFlow<List<Account>> = _accounts.asStateFlow()
@@ -125,9 +129,10 @@ class JellyfinSession @Inject constructor(
currentId = account.id currentId = account.id
} }
_accounts.value = accounts.sortedByDescending { it.lastUsedAt } _accounts.value = accounts.sortedByDescending { it.lastUsedAt }
val current = accounts.firstOrNull { it.id == currentId } ?: return false val current = accounts.firstOrNull { it.id == currentId }
activate(current) current?.let(::activate)
true _restored.value = true
current != null
} }
/** Saves the account of a successful login (see [ServerLogin]) and makes it current. */ /** Saves the account of a successful login (see [ServerLogin]) and makes it current. */

View File

@@ -38,13 +38,26 @@ data class TrackPick(val language: String?, val name: String?, val codec: String
/** /**
* Playback defaults from the settings screen, for everyone on this TV (they depend on the TV and the network). * Playback defaults from the settings screen, for everyone on this TV (they depend on the TV and the network).
* [maxBitrate] null = the original file; [dolbyVision] = decode Dolby Vision files with the DV decoder. * [maxBitrate] null = the original file; [dolbyVision] = decode Dolby Vision files with the DV decoder.
* [skipIntro]: openings, recaps, previews, ads; [skipCredits]: credits (then the next episode).
*/ */
data class PlaybackDefaults( data class PlaybackDefaults(
val maxBitrate: Int? = null, val maxBitrate: Int? = null,
val videoPath: VideoPathSwitchable.Mode = VideoPathSwitchable.Mode.AUTO, val videoPath: VideoPathSwitchable.Mode = VideoPathSwitchable.Mode.AUTO,
val dolbyVision: Boolean = false, val dolbyVision: Boolean = false,
val skipIntro: SkipMode = SkipMode.Button,
val skipCredits: SkipMode = SkipMode.Button,
) )
/** What the player does with a part of the video the server marked as skippable. */
enum class SkipMode {
/** Offers "Пропустить …". */
Button,
/** Skips it on its own. */
Auto,
/** Plays it as is. */
Off,
}
/** What the user picked last for a movie or a series. [subtitle] null with [subtitleOff] = subtitles off. */ /** What the user picked last for a movie or a series. [subtitle] null with [subtitleOff] = subtitles off. */
data class RememberedTracks(val audio: TrackPick?, val subtitle: TrackPick?, val subtitleOff: Boolean) data class RememberedTracks(val audio: TrackPick?, val subtitle: TrackPick?, val subtitleOff: Boolean)
@@ -70,6 +83,8 @@ class PlaybackPreferences @Inject constructor(
maxBitrate = prefs[KEY_MAX_BITRATE]?.takeIf { it > 0 }, maxBitrate = prefs[KEY_MAX_BITRATE]?.takeIf { it > 0 },
videoPath = prefs[KEY_VIDEO_PATH]?.let { runCatching { VideoPathSwitchable.Mode.valueOf(it) }.getOrNull() } ?: VideoPathSwitchable.Mode.AUTO, videoPath = prefs[KEY_VIDEO_PATH]?.let { runCatching { VideoPathSwitchable.Mode.valueOf(it) }.getOrNull() } ?: VideoPathSwitchable.Mode.AUTO,
dolbyVision = prefs[KEY_DOLBY_VISION] ?: false, dolbyVision = prefs[KEY_DOLBY_VISION] ?: false,
skipIntro = prefs[KEY_SKIP_INTRO].toSkipMode(),
skipCredits = prefs[KEY_SKIP_CREDITS].toSkipMode(),
) )
} }
@@ -80,9 +95,13 @@ class PlaybackPreferences @Inject constructor(
it[KEY_MAX_BITRATE] = defaults.maxBitrate ?: 0 it[KEY_MAX_BITRATE] = defaults.maxBitrate ?: 0
it[KEY_VIDEO_PATH] = defaults.videoPath.name it[KEY_VIDEO_PATH] = defaults.videoPath.name
it[KEY_DOLBY_VISION] = defaults.dolbyVision it[KEY_DOLBY_VISION] = defaults.dolbyVision
it[KEY_SKIP_INTRO] = defaults.skipIntro.name
it[KEY_SKIP_CREDITS] = defaults.skipCredits.name
} }
} }
private fun String?.toSkipMode(): SkipMode = this?.let { runCatching { SkipMode.valueOf(it) }.getOrNull() } ?: SkipMode.Button
/** [key]: the series of an episode, else the item itself. */ /** [key]: the series of an episode, else the item itself. */
suspend fun tracks(key: UUID): RememberedTracks? { suspend fun tracks(key: UUID): RememberedTracks? {
val raw = context.playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null val raw = context.playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
@@ -138,5 +157,7 @@ class PlaybackPreferences @Inject constructor(
val KEY_MAX_BITRATE = intPreferencesKey("max_bitrate") val KEY_MAX_BITRATE = intPreferencesKey("max_bitrate")
val KEY_VIDEO_PATH = stringPreferencesKey("video_path") val KEY_VIDEO_PATH = stringPreferencesKey("video_path")
val KEY_DOLBY_VISION = booleanPreferencesKey("dolby_vision") val KEY_DOLBY_VISION = booleanPreferencesKey("dolby_vision")
val KEY_SKIP_INTRO = stringPreferencesKey("skip_intro")
val KEY_SKIP_CREDITS = stringPreferencesKey("skip_credits")
} }
} }

View File

@@ -205,6 +205,19 @@ class PlaybackSession(
reportProgressAsync() reportProgressAsync()
} }
/**
* The app left the screen (Home on the remote) with the player open: pause, save the position on the server and
* say the item's progress changed, so lists outside the app (the TV's home screen) show it.
*/
fun pauseInBackground() {
val e = engine ?: return
if (e.state.value.playWhenReady) e.pause()
scope.launch {
reportProgress()
session.notifyUserDataChanged(item.id)
}
}
fun seekBy(deltaMs: Long) = seekTo(positionMs() + deltaMs) fun seekBy(deltaMs: Long) = seekTo(positionMs() + deltaMs)
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {