diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ba12aa4..2ac957c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,11 @@
+
+
+
+
+
@@ -21,6 +26,7 @@
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
android:exported="true"
+ android:launchMode="singleTask"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing">
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/LauncherFeed.kt b/app/src/main/kotlin/ru/stenfredd/movies/LauncherFeed.kt
new file mode 100644
index 0000000..323f7d9
--- /dev/null
+++ b/app/src/main/kotlin/ru/stenfredd/movies/LauncherFeed.kt
@@ -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", )`; 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
+ }
+}
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt b/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt
index edef076..669febf 100644
--- a/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt
+++ b/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt
@@ -1,5 +1,6 @@
package ru.stenfredd.movies
+import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -28,6 +29,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import dagger.hilt.android.AndroidEntryPoint
import androidx.compose.runtime.produceState
+import kotlinx.coroutines.channels.Channel
import ru.stenfredd.movies.core.data.LibraryRepository
import ru.stenfredd.movies.core.data.model.LibraryType
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.series.SeriesScreen
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.shell.RailLibrary
import ru.stenfredd.movies.ui.shell.Shell
@@ -67,6 +70,9 @@ class MainActivity : ComponentActivity() {
/** Account → its libraries on the rail, see [Root]. */
private var railLibraries: Pair> = "" to emptyList()
+ /** Launches while the app is already open (a card on the TV's home screen), see [onNewIntent]. */
+ private val launches = Channel(Channel.CONFLATED)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
@@ -75,15 +81,41 @@ class MainActivity : ComponentActivity() {
var ready by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
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
- if (loggedIn) intentTarget()?.let(stack::addAll)
+ if (loggedIn) intentTarget(intent)?.let(stack::addAll)
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
private fun Navigation(stack: MutableList) {
fun open(screen: Screen) {
@@ -200,15 +232,16 @@ class MainActivity : ComponentActivity() {
* Opens an item from the launch intent:
* `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item [--ez play true] [--el start ]
* [--ei audio ] [--ei sub ]` (stream indexes as Jellyfin numbers them; with one given, the other is off),
- * or a series page: `--es series [--es episode ]`.
+ * or a series page: `--es series [--es episode ]`. 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.
*/
- private fun intentTarget(): List? {
+ private fun intentTarget(intent: Intent): List? {
if (BuildConfig.DEBUG && intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1))
- intent.getStringExtra("series")?.let(::parseId)?.let { series ->
- return listOf(Screen.Series(series, intent.getStringExtra("episode")?.let(::parseId)))
- }
- val id = intent.getStringExtra("item")?.let(::parseId) ?: return null
+ val series = intent.getStringExtra("series")?.let(::parseId)
+ val id = intent.getStringExtra("item")?.let(::parseId)
+ if (series != null && id == null) return listOf(Screen.Series(series, intent.getStringExtra("episode")?.let(::parseId)))
+ if (id == null) return null
val engine = if (BuildConfig.DEBUG) intent.getStringExtra("engine")?.let(::EngineId) else null
val play = engine != null || intent.getBooleanExtra("play", false)
val streams = if (intent.hasExtra("audio") || intent.hasExtra("sub")) {
@@ -216,7 +249,7 @@ class MainActivity : ComponentActivity() {
} else {
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()
}
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/MoviesApp.kt b/app/src/main/kotlin/ru/stenfredd/movies/MoviesApp.kt
index e6cc87a..1b352e2 100644
--- a/app/src/main/kotlin/ru/stenfredd/movies/MoviesApp.kt
+++ b/app/src/main/kotlin/ru/stenfredd/movies/MoviesApp.kt
@@ -10,7 +10,12 @@ import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565
import coil3.request.crossfade
+import dagger.Lazy
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 timber.log.Timber
import javax.inject.Inject
@@ -19,10 +24,13 @@ import javax.inject.Inject
class MoviesApp : Application(), SingletonImageLoader.Factory {
/** 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 launcherFeed: Lazy
override fun onCreate() {
super.onCreate()
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() }
}
/**
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Cards.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Cards.kt
index 3e22544..7fe20a8 100644
--- a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Cards.kt
+++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Cards.kt
@@ -2,7 +2,6 @@ package ru.stenfredd.movies.ui.components
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.Box
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.text.style.TextOverflow
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.AppDimens
import ru.stenfredd.movies.ui.theme.AppType
@@ -47,6 +48,8 @@ data class PosterModel(
val watched: Boolean = false,
/** Heart in the corner (favourites); shown instead of the watched mark. */
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. */
@@ -57,6 +60,7 @@ data class WideModel(
val subtitle: String,
val image: RemoteImageSpec?,
val progress: Float?,
+ val watch: WatchTarget? = null,
)
private val PosterCorner = 7.dp
@@ -73,7 +77,7 @@ fun PosterCard(model: PosterModel, onClick: () -> Unit, modifier: Modifier = Mod
Modifier
.focusCard(FocusStyle.Poster)
.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())
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
.focusCard(FocusStyle.Wide)
.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)
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 progress: Float,
val remaining: String?,
+ val watch: WatchTarget? = null,
)
@Composable
@@ -158,7 +163,7 @@ fun FeaturedCard(model: FeaturedModel, onClick: () -> Unit, modifier: Modifier =
modifier
.focusCard(FocusStyle.Featured)
.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)
Column(Modifier.align(Alignment.BottomStart).padding(start = 20.dp, end = 20.dp, bottom = 22.dp)) {
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/menu/WatchMenu.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/menu/WatchMenu.kt
new file mode 100644
index 0000000..d33ffae
--- /dev/null
+++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/menu/WatchMenu.kt
@@ -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 { 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(null) }
+ var error by remember { mutableStateOf(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 = 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 непросмотренным", "Отметки и места остановки всех серий сбросятся"))
+ }
+}
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/model/CardMapper.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/model/CardMapper.kt
index f0d50bf..2eb21b3 100644
--- a/app/src/main/kotlin/ru/stenfredd/movies/ui/model/CardMapper.kt
+++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/model/CardMapper.kt
@@ -11,6 +11,7 @@ import ru.stenfredd.movies.ui.components.FeaturedModel
import ru.stenfredd.movies.ui.components.PosterModel
import ru.stenfredd.movies.ui.components.RemoteImageSpec
import ru.stenfredd.movies.ui.components.WideModel
+import ru.stenfredd.movies.ui.menu.WatchTarget
import ru.stenfredd.movies.ui.theme.AppDimens
import javax.inject.Inject
import kotlin.math.roundToInt
@@ -45,6 +46,7 @@ class CardMapper @Inject constructor(
progress = item.user.progress,
isNew = isNew(item),
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(" · "),
image = image(item.landscape(), AppDimens.WIDE_W.toFloat(), AppDimens.WIDE_H.toFloat()),
progress = item.user.progress,
+ watch = watchTarget(item),
)
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),
progress = item.user.progress ?: 0f,
remaining = item.remaining(),
+ watch = watchTarget(item),
)
}
@@ -98,18 +102,34 @@ class CardMapper @Inject constructor(
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? =
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? =
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
}
}
+
+/** 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()} мин"
+}
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerNext.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerNext.kt
index 33cb8a2..20bf221 100644
--- a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerNext.kt
+++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerNext.kt
@@ -41,7 +41,7 @@ import ru.stenfredd.movies.ui.components.focusCard
import ru.stenfredd.movies.ui.theme.AppColors
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
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
* "Досмотреть титры". 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
internal fun NextEpisodeCard(
next: NextEpisode,
remainingMs: Long,
+ countdownMs: Long,
playNow: FocusRequester,
onPlay: () -> Unit,
onStay: () -> Unit,
@@ -65,7 +66,7 @@ internal fun NextEpisodeCard(
) {
val shape = RoundedCornerShape(10.dp)
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(
modifier
.width(320.dp)
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerOsd.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerOsd.kt
index d46b7a2..d8c888c 100644
--- a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerOsd.kt
+++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerOsd.kt
@@ -39,6 +39,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
+import ru.stenfredd.movies.core.data.model.MediaSegment
import ru.stenfredd.movies.ui.components.AppIcon
import kotlin.math.roundToInt
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.
- * Drawn as plain rectangles in one draw call: no layers, no paths.
+ * Track with the buffered part, skippable parts (intro, credits), the played part and chapter marks; thicker with a
+ * knob when focused. Drawn as plain rectangles in one draw call: no layers, no paths.
*/
@Composable
-internal fun Timeline(positionMs: Long, durationMs: Long, bufferedMs: Long, chaptersMs: List, focused: Boolean, modifier: Modifier = Modifier) {
+internal fun Timeline(
+ positionMs: Long,
+ durationMs: Long,
+ bufferedMs: Long,
+ chaptersMs: List,
+ spans: List,
+ focused: Boolean,
+ modifier: Modifier = Modifier,
+) {
Spacer(
modifier
.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
drawRoundRect(Color(0x29FFFFFF), Offset(0f, top), Size(w, 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)
if (played > 0f) {
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. */
@Composable
internal fun OsdCircleButton(icon: AppIcon, size: Dp, iconSize: Dp, onClick: () -> Unit, modifier: Modifier = Modifier) {
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerScreen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerScreen.kt
index 6a9f274..952132d 100644
--- a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerScreen.kt
+++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerScreen.kt
@@ -51,6 +51,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import ru.stenfredd.movies.BuildConfig
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.toMediaInfo
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
* 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.
+ * 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
fun PlayerScreen(
@@ -130,6 +133,21 @@ private class PlayerUiState {
var nextDismissedFor by mutableStateOf(null)
/** The "next episode" card has focus: arrows and OK go to its buttons. */
var promptFocused = false
+ /** Where the "next episode" card appeared over known credits: its countdown runs from there. */
+ var nextFrom by mutableStateOf(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(null)
+ var noticeVersion by mutableIntStateOf(0)
val root = FocusRequester()
val playPause = FocusRequester()
@@ -138,6 +156,7 @@ private class PlayerUiState {
val drawerInitial = FocusRequester()
val errorButton = FocusRequester()
val nextPlay = FocusRequester()
+ val skipButton = FocusRequester()
val debugMenuFocus = FocusRequester()
val chips = PlayerPanel.entries.associateWith { FocusRequester() }
var panelFrom: FocusRequester? = null
@@ -171,6 +190,8 @@ private fun PlayerUi(
val info by vm.info.collectAsStateWithLifecycle()
val season by vm.season.collectAsStateWithLifecycle()
val style by vm.subtitleStyle.collectAsStateWithLifecycle()
+ val segments by vm.segments.collectAsStateWithLifecycle()
+ val defaults by vm.defaults.collectAsStateWithLifecycle()
val ui = remember { PlayerUiState() }
val focusManager = LocalFocusManager.current
val media = remember(state.source) { state.source?.toMediaInfo() }
@@ -179,10 +200,30 @@ private fun PlayerUi(
val ended = state.engineState.phase == EngineState.Phase.ENDED
val shownPosition = ui.scrubTarget ?: 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 next = season?.nextEpisode?.takeIf {
- info?.isEpisode == true && state.durationMs > 0 && remainingMs in 0..NEXT_PROMPT_MS && !ended && state.error == null &&
- ui.nextDismissedFor != state.itemId && ui.panel == null && !ui.drawer
+ val itemId = state.itemId
+ val nextEpisode = season?.takeIf { it.currentId == itemId && info?.isEpisode == true }?.nextEpisode
+ val plan = remember(segments, itemId, state.durationMs, defaults, nextEpisode != null) {
+ 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() {
@@ -192,6 +233,15 @@ private fun PlayerUi(
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) {
ui.osd = true
ui.focus(focus)
@@ -243,6 +293,7 @@ private fun PlayerUi(
ui.nextDismissedFor = state.itemId
ui.focus(ui.root)
}
+ skip != null -> ui.skipHidden = ui.skipHidden.plus(itemId, skip.segment)
else -> onExit()
}
}
@@ -277,7 +328,7 @@ private fun PlayerUi(
if (!focusManager.moveFocus(FocusDirection.Up) && repeat == 0) hideOsd()
}
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
}
@@ -297,6 +348,7 @@ private fun PlayerUi(
LaunchedEffect(ui.scrubVersion) {
val target = ui.scrubTarget ?: return@LaunchedEffect
delay(SCRUB_COMMIT_MS)
+ plan?.spanAt(target)?.let { ui.seekedInto = ui.seekedInto.plus(state.itemId, it) }
session.seekTo(target)
// Keep showing the target until the engine reports the new position.
delay(SCRUB_SETTLE_MS)
@@ -325,13 +377,45 @@ private fun PlayerUi(
LaunchedEffect(state.itemId) {
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.
val nextShown = next != null
LaunchedEffect(nextShown) {
if (nextShown && !ui.osd) ui.focus(ui.nextPlay)
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).
BackHandler { back() }
@@ -341,6 +425,7 @@ private fun PlayerUi(
.fillMaxSize()
.focusRequester(ui.root)
.onPreviewKeyEvent(::onKey)
+ .onFocusChanged { ui.hasFocus = it.hasFocus }
.focusable(),
) {
if (!ui.osd && !playing && !state.loading && state.error == null && !ended) PausedBadge(Modifier.align(Alignment.Center))
@@ -352,6 +437,7 @@ private fun PlayerUi(
media = media,
ui = ui,
positionMs = shownPosition,
+ spans = plan?.spans.orEmpty(),
onToggle = session::togglePlayPause,
onSeek = { scrub(it, 0) },
onEpisode = vm::playEpisode,
@@ -387,10 +473,29 @@ private fun PlayerUi(
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 ->
NextEpisodeCard(
n,
- remainingMs = remainingMs,
+ remainingMs = nextLeftMs,
+ countdownMs = nextCountdownMs,
playNow = ui.nextPlay,
onPlay = {
hideOsd()
@@ -402,8 +507,7 @@ private fun PlayerUi(
ui.focus(if (ui.osd) ui.playPause else ui.root)
},
onFocus = { ui.promptFocused = it },
- // Above the controls while they are shown.
- modifier = Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = if (ui.osd) 132.dp else 32.dp),
+ modifier = Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = promptBottom),
)
}
if (state.loading && state.error == null) LoadingBadge(Modifier.align(Alignment.Center))
@@ -443,6 +547,7 @@ private fun onNavigationKey(
showOsd: (FocusRequester) -> Unit,
openDrawer: () -> Unit,
promptShown: Boolean,
+ skipShown: Boolean,
): Boolean {
if (BuildConfig.DEBUG && !ui.debugMenu) {
when (code) {
@@ -460,6 +565,8 @@ private fun onNavigationKey(
if (ui.panel != null || ui.drawer || ui.debugMenu) return false
// 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
+ // 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) {
when (code) {
AndroidKeyEvent.KEYCODE_DPAD_UP if tour != null -> {
@@ -505,6 +612,7 @@ private fun OsdLayer(
media: MediaInfo?,
ui: PlayerUiState,
positionMs: Long,
+ spans: List,
onToggle: () -> Unit,
onSeek: (Int) -> Unit,
onEpisode: (UUID) -> Unit,
@@ -542,6 +650,7 @@ private fun OsdLayer(
durationMs = state.durationMs,
bufferedMs = state.bufferedMs,
chaptersMs = info?.chaptersMs.orEmpty(),
+ spans = spans,
focused = ui.timelineFocused,
modifier = Modifier
.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 PROMPT_KEYS = setOf(
- AndroidKeyEvent.KEYCODE_DPAD_LEFT,
- AndroidKeyEvent.KEYCODE_DPAD_RIGHT,
- AndroidKeyEvent.KEYCODE_DPAD_CENTER,
- AndroidKeyEvent.KEYCODE_ENTER,
- AndroidKeyEvent.KEYCODE_NUMPAD_ENTER,
-)
+private val OK_KEYS = setOf(AndroidKeyEvent.KEYCODE_DPAD_CENTER, AndroidKeyEvent.KEYCODE_ENTER, AndroidKeyEvent.KEYCODE_NUMPAD_ENTER)
+
+private val PROMPT_KEYS = OK_KEYS + setOf(AndroidKeyEvent.KEYCODE_DPAD_LEFT, AndroidKeyEvent.KEYCODE_DPAD_RIGHT)
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 BOTTOM_SHADE = 170
private const val SCRUB_COMMIT_MS = 600L
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerSkip.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerSkip.kt
new file mode 100644
index 0000000..d2fbc06
--- /dev/null
+++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerSkip.kt
@@ -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)
+
+/** 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, val credits: MediaSegment?, val creditsAuto: Boolean) {
+ /** Parts drawn on the timeline. */
+ val spans: List = 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, 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 = emptySet()) {
+ fun of(id: UUID?): Set = 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
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerViewModel.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerViewModel.kt
index 74a90fa..3e7c5db 100644
--- a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerViewModel.kt
+++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerViewModel.kt
@@ -24,6 +24,7 @@ import ru.stenfredd.movies.playback.api.StreamSelection
import ru.stenfredd.movies.playback.api.SubtitleStyle
import ru.stenfredd.movies.playback.core.DeviceCapabilitiesProbe
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.PlaybackSession
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 ⏮/⏭. */
@Immutable
data class SeasonEpisodes(
+ /** The episode being played: until the next one's season loads, this still describes the previous one. */
+ val currentId: UUID,
/** "СЕЗОН 2 · СЕРИИ" */
val title: String,
/** "10 серий · 3 просмотрено" */
@@ -122,10 +125,18 @@ class PlayerViewModel @javax.inject.Inject constructor(
private val _season = MutableStateFlow(null)
val season: StateFlow = _season.asStateFlow()
+ private val _segments = MutableStateFlow(null)
+ /** Intro, credits …: skip buttons and the "next episode" card. */
+ val segments: StateFlow = _segments.asStateFlow()
+
val subtitleStyle: StateFlow = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
+ /** For the skip settings. */
+ val defaults: StateFlow = prefs.defaults.stateIn(viewModelScope, SharingStarted.Eagerly, PlaybackDefaults())
+
private var startedFor: Screen.Player? = 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. */
private var maxBitrate: Int? = null
private var qualityChosen = false
@@ -179,6 +190,7 @@ class PlayerViewModel @javax.inject.Inject constructor(
override fun onCleared() = release()
private fun loadInfo(itemId: UUID) {
+ loadSegments(itemId)
infoJob?.cancel()
if (_info.value?.id != itemId) _info.value = null
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): SeasonEpisodes {
val index = all.indexOfFirst { it.id == current.id }
val inSeason = all.filter { it.seasonId == current.seasonId }
val watched = inSeason.count { it.user.played }
return SeasonEpisodes(
+ currentId = current.id,
title = listOfNotNull(current.seasonNumber?.let { "СЕЗОН $it" }, "СЕРИИ").joinToString(" · "),
stat = "${inSeason.size} ${plural(inSeason.size, "серия", "серии", "серий")} · $watched просмотрено",
episodes = inSeason.map { e ->
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/series/SeriesScreen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/series/SeriesScreen.kt
index 83a66b3..86c36e7 100644
--- a/app/src/main/kotlin/ru/stenfredd/movies/ui/series/SeriesScreen.kt
+++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/series/SeriesScreen.kt
@@ -84,6 +84,8 @@ import ru.stenfredd.movies.ui.focus.Restore
import ru.stenfredd.movies.ui.focus.ScreenFocus
import ru.stenfredd.movies.ui.focus.rememberScreenFocus
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.AppDimens
import ru.stenfredd.movies.ui.theme.AppType
@@ -118,6 +120,7 @@ fun SeriesScreen(
private const val KEY_PLAY = "play"
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.
@@ -288,6 +291,15 @@ private fun Header(
Modifier.remembered(focus, "favorite"),
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)
.border(0.5.dp, if (focused) AppColors.FocusFill else if (selected) Color(0xFF7A6CFF) else AppColors.Border, shape)
.onFocusChanged { focused = it.isFocused }
- .clickable(interactionSource = null, indication = null, onClick = onClick)
+ .clickableWithMenu(season.watch, onClick)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -594,7 +606,7 @@ private fun EpisodeCard(e: SeriesEpisode, onClick: () -> Unit, modifier: Modifie
modifier
.focusCard(EpisodeFocus)
.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())
BasicText(
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/series/SeriesViewModel.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/series/SeriesViewModel.kt
index 95d09f4..a64a0b1 100644
--- a/app/src/main/kotlin/ru/stenfredd/movies/ui/series/SeriesViewModel.kt
+++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/series/SeriesViewModel.kt
@@ -25,6 +25,7 @@ import ru.stenfredd.movies.core.data.LibraryRepository
import ru.stenfredd.movies.core.data.SessionCache
import ru.stenfredd.movies.core.data.WatchRepository
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.MediaInfo
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.TrackChoices
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.player.PlayerLabels
import ru.stenfredd.movies.ui.model.plural
@@ -70,6 +72,8 @@ data class SeriesEpisode(
val progress: Float?,
val state: EpisodeState,
val resumeMs: Long,
+ /** What a long press on the card marks watched. */
+ val watch: WatchTarget,
)
@Immutable
@@ -88,6 +92,8 @@ data class SeriesSeason(
val target: Int,
/** Row index of the season's divider. */
val dividerIndex: Int,
+ /** The whole season, for a long press on its chip. */
+ val watch: WatchTarget,
)
@Immutable
@@ -103,6 +109,8 @@ data class SeriesModel(
val poster: RemoteImageSpec?,
val backdropUrl: String?,
val favorite: Boolean,
+ /** The whole series, for the ✓ button; played when every episode is. */
+ val watch: WatchTarget,
val seasons: List,
val episodes: List,
/** Index of the episode "Продолжить"/"Смотреть" plays. */
@@ -255,14 +263,15 @@ class SeriesViewModel @AssistedInject constructor(
val first = episodes.size
val dividerIndex = row++
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 watched = own.count { it.state == EpisodeState.Watched }
val number = season.seasonNumber
+ val label = if (number != null && number > 0) "Сезон $number" else season.name
seasons += SeriesSeason(
key = "season/${season.id}",
- label = if (number != null && number > 0) "Сезон $number" else season.name,
+ label = label,
stat = when {
watched == own.size -> "✓"
watched == 0 && own.all { it.state == EpisodeState.New } -> "новый"
@@ -277,6 +286,7 @@ class SeriesViewModel @AssistedInject constructor(
?: 0
),
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),
backdropUrl = item.backdrop?.let { images.url(it, cards.px(SeriesLayout.BACKDROP_W), cards.px(SeriesLayout.BACKDROP_H), quality = 80) },
favorite = item.user.favorite,
+ watch = groupTarget(item.id, ItemKind.Series, item.name, null, episodes),
seasons = seasons,
episodes = episodes,
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
// Some files have no episode number: their place in the season stands in.
val number = e.episodeNumber ?: (position + 1)
@@ -335,6 +346,29 @@ class SeriesViewModel @AssistedInject constructor(
progress = progress,
state = state,
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): 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 },
)
}
diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/settings/SettingsScreen.kt
index f2ab00f..4bc870d 100644
--- a/app/src/main/kotlin/ru/stenfredd/movies/ui/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/settings/SettingsScreen.kt
@@ -60,6 +60,7 @@ import ru.stenfredd.movies.core.jellyfin.Account
import ru.stenfredd.movies.playback.api.SubtitleStyle
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
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.components.Avatar
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)) }
}
}
+ 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 "Японский")
diff --git a/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/LibraryRepository.kt b/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/LibraryRepository.kt
index cf7ff75..e26a91d 100644
--- a/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/LibraryRepository.kt
+++ b/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/LibraryRepository.kt
@@ -3,6 +3,7 @@ package ru.stenfredd.movies.core.data
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.jellyfin.sdk.api.client.extensions.libraryApi
+import org.jellyfin.sdk.api.client.extensions.mediaSegmentApi
import org.jellyfin.sdk.api.client.extensions.personApi
import org.jellyfin.sdk.api.client.extensions.showApi
import org.jellyfin.sdk.api.client.extensions.userViewApi
@@ -18,6 +19,7 @@ import ru.stenfredd.movies.core.data.model.ItemSort
import ru.stenfredd.movies.core.data.model.ItemSummary
import ru.stenfredd.movies.core.data.model.LibraryType
import ru.stenfredd.movies.core.data.model.LibraryView
+import ru.stenfredd.movies.core.data.model.MediaSegment
import ru.stenfredd.movies.core.data.model.Page
import ru.stenfredd.movies.core.data.model.Person
import ru.stenfredd.movies.core.data.model.itemKinds
@@ -101,6 +103,11 @@ class LibraryRepository @Inject constructor(
session.call { libraryApi.getItem(itemId = id, userId = userId).content }.toDetails()
}
+ /** Intro, credits and other skippable parts of [id] in time order; empty when the server has none. */
+ suspend fun segments(id: UUID): List = 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 = cache.get("seasons:$seriesId", Kind.UserData) {
session.call { showApi.getSeasons(seriesId = seriesId, userId = userId, fields = CARD_FIELDS).content.items }.map { it.toSummary() }
}
diff --git a/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/Mapping.kt b/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/Mapping.kt
index fdd9e8d..8897c20 100644
--- a/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/Mapping.kt
+++ b/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/Mapping.kt
@@ -7,6 +7,8 @@ import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
+import org.jellyfin.sdk.model.api.MediaSegmentDto
+import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
@@ -21,8 +23,10 @@ import ru.stenfredd.movies.core.data.model.ItemSummary
import ru.stenfredd.movies.core.data.model.LibraryType
import ru.stenfredd.movies.core.data.model.LibraryView
import ru.stenfredd.movies.core.data.model.MediaInfo
+import ru.stenfredd.movies.core.data.model.MediaSegment
import ru.stenfredd.movies.core.data.model.Person
import ru.stenfredd.movies.core.data.model.PersonKind
+import ru.stenfredd.movies.core.data.model.SegmentKind
import ru.stenfredd.movies.core.data.model.SubtitleTrack
import ru.stenfredd.movies.core.data.model.UserState
import ru.stenfredd.movies.core.data.model.VideoRange
@@ -100,6 +104,19 @@ internal fun BaseItemDto.toDetails(): ItemDetails = ItemDetails(
chapters = chapters.orEmpty().map { Chapter(it.name.orEmpty(), it.startPositionTicks / TICKS_PER_MS) },
)
+/** null for segments of unknown kind and empty ones. */
+internal fun MediaSegmentDto.toSegment(): MediaSegment? {
+ val kind = when (type) {
+ MediaSegmentType.INTRO -> SegmentKind.Intro
+ MediaSegmentType.RECAP -> SegmentKind.Recap
+ MediaSegmentType.PREVIEW -> SegmentKind.Preview
+ MediaSegmentType.OUTRO -> SegmentKind.Outro
+ MediaSegmentType.COMMERCIAL -> SegmentKind.Commercial
+ MediaSegmentType.UNKNOWN -> return null
+ }
+ return MediaSegment(kind, startTicks / TICKS_PER_MS, endTicks / TICKS_PER_MS).takeIf { it.endMs > it.startMs }
+}
+
internal fun BaseItemDto.toLibraryView(): LibraryView = LibraryView(
id = id,
name = name.orEmpty(),
diff --git a/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/WatchRepository.kt b/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/WatchRepository.kt
index 6e2236b..be55a4d 100644
--- a/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/WatchRepository.kt
+++ b/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/WatchRepository.kt
@@ -43,15 +43,15 @@ class WatchRepository @Inject constructor(
}
/**
- * "Continue watching": started items first (most recent first), then the next episode of each series in progress
- * that isn't already there. Of one library when [parentId] is given.
+ * "Continue watching", one card per movie or series: started items first (most recent first; of a series only its
+ * most recently watched episode), then the next episode of each series in progress that has none started.
+ * Of one library when [parentId] is given.
*/
suspend fun continueWatching(limit: Int = 20, parentId: UUID? = null): List = coroutineScope {
- val resume = async { resume(limit, parentId) }
+ // Several started episodes of a series fold into one card: fetch more so the list still fills up.
+ val resume = async { resume(limit * RESUME_PER_CARD, parentId) }
val nextUp = async { nextUp(limit, parentId) }
- val started = resume.await()
- val seriesStarted = started.mapNotNullTo(HashSet()) { it.seriesId }
- (started + nextUp.await().filter { it.seriesId !in seriesStarted && started.none { s -> s.id == it.id } }).take(limit)
+ (resume.await() + nextUp.await()).distinctBy { it.seriesId ?: it.id }.take(limit)
}
/** Watched movies and episodes, most recent first; of one library when [parentId] is given. */
@@ -82,5 +82,7 @@ class WatchRepository @Inject constructor(
private companion object {
const val FAVORITES_LIMIT = 500
+ /** Started items fetched per "continue watching" card. */
+ const val RESUME_PER_CARD = 3
}
}
diff --git a/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/model/Models.kt b/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/model/Models.kt
index e0ef5a9..ecd7307 100644
--- a/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/model/Models.kt
+++ b/core/data/src/main/kotlin/ru/stenfredd/movies/core/data/model/Models.kt
@@ -128,6 +128,21 @@ data class ItemDetails(
data class Chapter(val name: String, val startMs: Long)
+/** A part of a movie or an episode the player can skip, from the server's Media Segments (e.g. the Intro Skipper plugin). */
+data class MediaSegment(val kind: SegmentKind, val startMs: Long, val endMs: Long)
+
+enum class SegmentKind {
+ /** Opening, title sequence. */
+ Intro,
+ /** "Previously on". */
+ Recap,
+ /** A look at the next episode. */
+ Preview,
+ /** Credits, ending. */
+ Outro,
+ Commercial,
+}
+
/** The file of a playable item: what the format badges and the "file" line show, and the track choices. */
data class MediaInfo(
val sourceId: String,
diff --git a/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/JellyfinSession.kt b/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/JellyfinSession.kt
index 83c9842..b817050 100644
--- a/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/JellyfinSession.kt
+++ b/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/JellyfinSession.kt
@@ -65,6 +65,10 @@ class JellyfinSession @Inject constructor(
private val _session = MutableStateFlow(null)
val session: StateFlow = _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 = _restored.asStateFlow()
+
private val _accounts = MutableStateFlow>(emptyList())
/** Saved accounts, most recently used first. */
val accounts: StateFlow> = _accounts.asStateFlow()
@@ -125,9 +129,10 @@ class JellyfinSession @Inject constructor(
currentId = account.id
}
_accounts.value = accounts.sortedByDescending { it.lastUsedAt }
- val current = accounts.firstOrNull { it.id == currentId } ?: return false
- activate(current)
- true
+ val current = accounts.firstOrNull { it.id == currentId }
+ current?.let(::activate)
+ _restored.value = true
+ current != null
}
/** Saves the account of a successful login (see [ServerLogin]) and makes it current. */
diff --git a/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackPreferences.kt b/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackPreferences.kt
index f56df59..5bfb5da 100644
--- a/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackPreferences.kt
+++ b/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackPreferences.kt
@@ -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).
* [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(
val maxBitrate: Int? = null,
val videoPath: VideoPathSwitchable.Mode = VideoPathSwitchable.Mode.AUTO,
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. */
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 },
videoPath = prefs[KEY_VIDEO_PATH]?.let { runCatching { VideoPathSwitchable.Mode.valueOf(it) }.getOrNull() } ?: VideoPathSwitchable.Mode.AUTO,
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_VIDEO_PATH] = defaults.videoPath.name
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. */
suspend fun tracks(key: UUID): RememberedTracks? {
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_VIDEO_PATH = stringPreferencesKey("video_path")
val KEY_DOLBY_VISION = booleanPreferencesKey("dolby_vision")
+ val KEY_SKIP_INTRO = stringPreferencesKey("skip_intro")
+ val KEY_SKIP_CREDITS = stringPreferencesKey("skip_credits")
}
}
diff --git a/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackSession.kt b/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackSession.kt
index 9c97920..32e3b5b 100644
--- a/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackSession.kt
+++ b/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackSession.kt
@@ -205,6 +205,19 @@ class PlaybackSession(
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 seekTo(positionMs: Long) {