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) {