Continue watching feed, watched marks, intro skipping
This commit is contained in:
@@ -3,6 +3,11 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<!-- The TV's own home screen takes "Продолжить просмотр" from the app (LauncherFeed). -->
|
||||||
|
<queries>
|
||||||
|
<provider android:authorities="ru.stenfredd.tvhome.feed" />
|
||||||
|
</queries>
|
||||||
|
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
|
|
||||||
@@ -21,6 +26,7 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
|
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
android:screenOrientation="landscape"
|
android:screenOrientation="landscape"
|
||||||
android:windowSoftInputMode="adjustNothing">
|
android:windowSoftInputMode="adjustNothing">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
154
app/src/main/kotlin/ru/stenfredd/movies/LauncherFeed.kt
Normal file
154
app/src/main/kotlin/ru/stenfredd/movies/LauncherFeed.kt
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package ru.stenfredd.movies
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import ru.stenfredd.movies.core.data.ImageUrls
|
||||||
|
import ru.stenfredd.movies.core.data.SessionCache
|
||||||
|
import ru.stenfredd.movies.core.data.WatchRepository
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemKind
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||||
|
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||||
|
import ru.stenfredd.movies.ui.model.landscape
|
||||||
|
import ru.stenfredd.movies.ui.model.remaining
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Продолжить просмотр" on the TV's own home screen (the `ru.stenfredd.tvhome` shell): the app sends the shell the
|
||||||
|
* ready list whenever it changes, and the shell keeps and shows it without waking this app on every Home press.
|
||||||
|
*
|
||||||
|
* Contract v1: `call(content://ru.stenfredd.tvhome.feed, "continue_watching", <JSON array of cards>)`; the shell
|
||||||
|
* accepts it only from an app signed with its own key. An empty array hides the row. No shell on the device (another
|
||||||
|
* TV, an emulator) → nothing is computed or sent.
|
||||||
|
*
|
||||||
|
* Recomputed once per process start, when the account changes (sign-in, profile switch, sign-out) and when watch
|
||||||
|
* state changes ([SessionCache.invalidations]: playback stopped or left with Home, marked watched or favourite).
|
||||||
|
* Progress made on other devices shows up only at the next start: no periodic background work on this TV.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class LauncherFeed @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val session: JellyfinSession,
|
||||||
|
private val cache: SessionCache,
|
||||||
|
private val watch: WatchRepository,
|
||||||
|
private val images: ImageUrls,
|
||||||
|
) {
|
||||||
|
/** What the shell has from this process; it is sent again after a restart in case the shell lost it. */
|
||||||
|
private var lastSent: String? = null
|
||||||
|
|
||||||
|
/** Runs for the life of the process; call off the main thread. */
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
suspend fun run() {
|
||||||
|
session.restored.first { it }
|
||||||
|
// Home loads the same lists at start: after it they come from the cache, and the start isn't slowed down.
|
||||||
|
delay(START_DELAY_MS)
|
||||||
|
merge(session.session.map { it?.accountId }.distinctUntilChanged(), cache.invalidations)
|
||||||
|
.debounce(DEBOUNCE_MS)
|
||||||
|
.collectLatest { publish() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION") // the flags overload is API 33+
|
||||||
|
private suspend fun publish() {
|
||||||
|
if (context.packageManager.resolveContentProvider(AUTHORITY, 0) == null) return
|
||||||
|
val account = session.session.value?.accountId
|
||||||
|
val items = if (account == null) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// The home screen's own call (same cache entries), so usually no request at all.
|
||||||
|
watch.continueWatching().take(MAX_ITEMS)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// The shell keeps the previous list: better than an empty row.
|
||||||
|
Timber.w(e, "Launcher feed: not updated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session.session.value?.accountId != account) return
|
||||||
|
val json = JSONArray(items.map(::card)).toString()
|
||||||
|
if (json == lastSent) return
|
||||||
|
try {
|
||||||
|
context.contentResolver.call(Uri.parse("content://$AUTHORITY"), METHOD, json, null)
|
||||||
|
lastSent = json
|
||||||
|
Timber.i("Launcher feed: sent %d cards", items.size)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
Timber.d("Launcher feed: no shell (%s)", e.message)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// The shell takes lists only from apps signed with its key: sign both with the same release key.
|
||||||
|
Timber.w(e, "Launcher feed: refused by the shell")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun card(item: ItemSummary): JSONObject {
|
||||||
|
val started = item.user.progress != null
|
||||||
|
return JSONObject()
|
||||||
|
.put("id", item.id.hex())
|
||||||
|
.put("title", title(item))
|
||||||
|
.putOpt("subtitle", if (started) item.remaining() else if (item.kind == ItemKind.Episode) "следующая серия" else null)
|
||||||
|
.putOpt("progress", item.user.progress?.let { (it * 1000).roundToInt() / 1000.0 })
|
||||||
|
.putOpt("image", item.landscape()?.let { direct(images.url(it, IMAGE_W, IMAGE_H)) })
|
||||||
|
.put("intent", intent(item).toUri(Intent.URI_INTENT_SCHEME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line of ~28 characters: "Сериал · S2 E5" or the movie's name (an episode's name doesn't fit). */
|
||||||
|
private fun title(item: ItemSummary): String {
|
||||||
|
val series = item.seriesName?.takeIf { item.kind == ItemKind.Episode } ?: return item.name
|
||||||
|
val number = listOfNotNull(item.seasonNumber?.let { "S$it" }, item.episodeNumber?.let { "E$it" }).joinToString(" ")
|
||||||
|
return if (number.isEmpty()) series else "$series · $number"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays the item from where it was left (a next episode from the start), with the item's page under the player:
|
||||||
|
* a movie's page, an episode's series page on that episode. See `MainActivity.intentTarget`.
|
||||||
|
*/
|
||||||
|
private fun intent(item: ItemSummary): Intent = Intent()
|
||||||
|
.setClassName(context.packageName, MainActivity::class.java.name)
|
||||||
|
.putExtra("item", item.id.hex())
|
||||||
|
.putExtra("play", true)
|
||||||
|
.apply {
|
||||||
|
if (item.kind == ItemKind.Episode) item.seriesId?.let { putExtra("series", it.hex()) }
|
||||||
|
if (item.user.positionMs > 0) putExtra("start", item.user.positionMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shell downloads covers itself, without the app's routing (`ServerRouting`): at home give it the LAN
|
||||||
|
* address the app uses, else the public one. The tag stays in the URL, so a changed image is a new URL.
|
||||||
|
*/
|
||||||
|
private fun direct(url: String): String {
|
||||||
|
val local = session.localUrl ?: return url
|
||||||
|
val public = session.session.value?.serverUrl?.trimEnd('/') ?: return url
|
||||||
|
return if (url.startsWith(public)) local + url.removePrefix(public) else url
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jellyfin's own form of ids: no dashes. */
|
||||||
|
private fun UUID.hex() = toString().replace("-", "")
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val AUTHORITY = "ru.stenfredd.tvhome.feed"
|
||||||
|
const val METHOD = "continue_watching"
|
||||||
|
/** The shell keeps up to 20 and shows 5. */
|
||||||
|
const val MAX_ITEMS = 10
|
||||||
|
/** The shell's card image, px. */
|
||||||
|
const val IMAGE_W = 326
|
||||||
|
const val IMAGE_H = 184
|
||||||
|
const val START_DELAY_MS = 3_000L
|
||||||
|
const val DEBOUNCE_MS = 1_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package ru.stenfredd.movies
|
package ru.stenfredd.movies
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
@@ -28,6 +29,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
|||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import ru.stenfredd.movies.core.data.LibraryRepository
|
import ru.stenfredd.movies.core.data.LibraryRepository
|
||||||
import ru.stenfredd.movies.core.data.model.LibraryType
|
import ru.stenfredd.movies.core.data.model.LibraryType
|
||||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||||
@@ -48,6 +50,7 @@ import ru.stenfredd.movies.ui.settings.SettingsScreen
|
|||||||
import ru.stenfredd.movies.ui.player.PlayerScreen
|
import ru.stenfredd.movies.ui.player.PlayerScreen
|
||||||
import ru.stenfredd.movies.ui.series.SeriesScreen
|
import ru.stenfredd.movies.ui.series.SeriesScreen
|
||||||
import ru.stenfredd.movies.ui.login.LoginScreen
|
import ru.stenfredd.movies.ui.login.LoginScreen
|
||||||
|
import ru.stenfredd.movies.ui.menu.WatchMenuHost
|
||||||
import ru.stenfredd.movies.ui.components.AppIcon
|
import ru.stenfredd.movies.ui.components.AppIcon
|
||||||
import ru.stenfredd.movies.ui.shell.RailLibrary
|
import ru.stenfredd.movies.ui.shell.RailLibrary
|
||||||
import ru.stenfredd.movies.ui.shell.Shell
|
import ru.stenfredd.movies.ui.shell.Shell
|
||||||
@@ -67,6 +70,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
/** Account → its libraries on the rail, see [Root]. */
|
/** Account → its libraries on the rail, see [Root]. */
|
||||||
private var railLibraries: Pair<String, List<RailLibrary>> = "" to emptyList()
|
private var railLibraries: Pair<String, List<RailLibrary>> = "" to emptyList()
|
||||||
|
|
||||||
|
/** Launches while the app is already open (a card on the TV's home screen), see [onNewIntent]. */
|
||||||
|
private val launches = Channel<Intent>(Channel.CONFLATED)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
@@ -75,15 +81,41 @@ class MainActivity : ComponentActivity() {
|
|||||||
var ready by remember { mutableStateOf(false) }
|
var ready by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val loggedIn = session.restore()
|
val loggedIn = session.restore()
|
||||||
|
// One that came during the restore is already [getIntent], read just below.
|
||||||
|
launches.tryReceive()
|
||||||
stack += if (loggedIn) root(Tab.Home) else Screen.Login
|
stack += if (loggedIn) root(Tab.Home) else Screen.Login
|
||||||
if (loggedIn) intentTarget()?.let(stack::addAll)
|
if (loggedIn) intentTarget(intent)?.let(stack::addAll)
|
||||||
ready = true
|
ready = true
|
||||||
}
|
}
|
||||||
if (ready) Navigation(stack)
|
if (ready) {
|
||||||
|
// Long press on a card (or ✓ on the series page): mark watched / unwatched, over any screen.
|
||||||
|
WatchMenuHost { Navigation(stack) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
for (launch in launches) {
|
||||||
|
// Signed out: the login screen stays, as at a cold start. No target (the app's icon): stay where the app was.
|
||||||
|
if (session.session.value == null) continue
|
||||||
|
val target = intentTarget(launch) ?: continue
|
||||||
|
// As at a cold start, so Back leads to the app's home. An open player is released and saves its position.
|
||||||
|
stack.clear()
|
||||||
|
stack += root(Tab.Home)
|
||||||
|
stack += target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The activity is `singleTask`: a launch from outside while it exists (the TV's home screen starts cards with
|
||||||
|
* `FLAG_ACTIVITY_NEW_TASK`) comes here instead of only bringing the old task with its old screen to the front.
|
||||||
|
*/
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
launches.trySend(intent)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Navigation(stack: MutableList<Screen>) {
|
private fun Navigation(stack: MutableList<Screen>) {
|
||||||
fun open(screen: Screen) {
|
fun open(screen: Screen) {
|
||||||
@@ -200,15 +232,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
* Opens an item from the launch intent:
|
* Opens an item from the launch intent:
|
||||||
* `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item <id> [--ez play true] [--el start <ms>]
|
* `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item <id> [--ez play true] [--el start <ms>]
|
||||||
* [--ei audio <index>] [--ei sub <index>]` (stream indexes as Jellyfin numbers them; with one given, the other is off),
|
* [--ei audio <index>] [--ei sub <index>]` (stream indexes as Jellyfin numbers them; with one given, the other is off),
|
||||||
* or a series page: `--es series <id> [--es episode <id>]`.
|
* or a series page: `--es series <id> [--es episode <id>]`. With both `item` (an episode) and `series`, the page
|
||||||
|
* under the player is the series page on that episode, as from the app's own cards (the TV's home screen does so).
|
||||||
* Debug builds also take `--es engine exo|vlc` (implies play) and `--ez tour true [--ei tour_file N]` for the debug tour.
|
* Debug builds also take `--es engine exo|vlc` (implies play) and `--ez tour true [--ei tour_file N]` for the debug tour.
|
||||||
*/
|
*/
|
||||||
private fun intentTarget(): List<Screen>? {
|
private fun intentTarget(intent: Intent): List<Screen>? {
|
||||||
if (BuildConfig.DEBUG && intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1))
|
if (BuildConfig.DEBUG && intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1))
|
||||||
intent.getStringExtra("series")?.let(::parseId)?.let { series ->
|
val series = intent.getStringExtra("series")?.let(::parseId)
|
||||||
return listOf(Screen.Series(series, intent.getStringExtra("episode")?.let(::parseId)))
|
val id = intent.getStringExtra("item")?.let(::parseId)
|
||||||
}
|
if (series != null && id == null) return listOf(Screen.Series(series, intent.getStringExtra("episode")?.let(::parseId)))
|
||||||
val id = intent.getStringExtra("item")?.let(::parseId) ?: return null
|
if (id == null) return null
|
||||||
val engine = if (BuildConfig.DEBUG) intent.getStringExtra("engine")?.let(::EngineId) else null
|
val engine = if (BuildConfig.DEBUG) intent.getStringExtra("engine")?.let(::EngineId) else null
|
||||||
val play = engine != null || intent.getBooleanExtra("play", false)
|
val play = engine != null || intent.getBooleanExtra("play", false)
|
||||||
val streams = if (intent.hasExtra("audio") || intent.hasExtra("sub")) {
|
val streams = if (intent.hasExtra("audio") || intent.hasExtra("sub")) {
|
||||||
@@ -216,7 +249,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
return listOf(Screen.Detail(id)) +
|
return listOf(series?.let { Screen.Series(it, id) } ?: Screen.Detail(id)) +
|
||||||
if (play) listOf(Screen.Player(id, engine, intent.getLongExtra("start", 0), streams = streams)) else emptyList()
|
if (play) listOf(Screen.Player(id, engine, intent.getLongExtra("start", 0), streams = streams)) else emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import coil3.memory.MemoryCache
|
|||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import coil3.request.allowRgb565
|
import coil3.request.allowRgb565
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
|
import dagger.Lazy
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -19,10 +24,13 @@ import javax.inject.Inject
|
|||||||
class MoviesApp : Application(), SingletonImageLoader.Factory {
|
class MoviesApp : Application(), SingletonImageLoader.Factory {
|
||||||
/** The API's client: images go to the server's LAN address at home too (`ServerRouting`), sharing its connections. */
|
/** The API's client: images go to the server's LAN address at home too (`ServerRouting`), sharing its connections. */
|
||||||
@Inject lateinit var httpClient: OkHttpClient
|
@Inject lateinit var httpClient: OkHttpClient
|
||||||
|
@Inject lateinit var launcherFeed: Lazy<LauncherFeed>
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Timber.plant(Timber.DebugTree())
|
Timber.plant(Timber.DebugTree())
|
||||||
|
// Built and run off the main thread: the first screen must not wait for it.
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { launcherFeed.get().run() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package ru.stenfredd.movies.ui.components
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -28,6 +27,8 @@ import androidx.compose.ui.geometry.Size
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import ru.stenfredd.movies.ui.menu.WatchTarget
|
||||||
|
import ru.stenfredd.movies.ui.menu.clickableWithMenu
|
||||||
import ru.stenfredd.movies.ui.theme.AppColors
|
import ru.stenfredd.movies.ui.theme.AppColors
|
||||||
import ru.stenfredd.movies.ui.theme.AppDimens
|
import ru.stenfredd.movies.ui.theme.AppDimens
|
||||||
import ru.stenfredd.movies.ui.theme.AppType
|
import ru.stenfredd.movies.ui.theme.AppType
|
||||||
@@ -47,6 +48,8 @@ data class PosterModel(
|
|||||||
val watched: Boolean = false,
|
val watched: Boolean = false,
|
||||||
/** Heart in the corner (favourites); shown instead of the watched mark. */
|
/** Heart in the corner (favourites); shown instead of the watched mark. */
|
||||||
val favorite: Boolean = false,
|
val favorite: Boolean = false,
|
||||||
|
/** What a long press marks watched or unwatched. */
|
||||||
|
val watch: WatchTarget? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Episode still or backdrop with a title over it: "Continue watching" cards. */
|
/** Episode still or backdrop with a title over it: "Continue watching" cards. */
|
||||||
@@ -57,6 +60,7 @@ data class WideModel(
|
|||||||
val subtitle: String,
|
val subtitle: String,
|
||||||
val image: RemoteImageSpec?,
|
val image: RemoteImageSpec?,
|
||||||
val progress: Float?,
|
val progress: Float?,
|
||||||
|
val watch: WatchTarget? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val PosterCorner = 7.dp
|
private val PosterCorner = 7.dp
|
||||||
@@ -73,7 +77,7 @@ fun PosterCard(model: PosterModel, onClick: () -> Unit, modifier: Modifier = Mod
|
|||||||
Modifier
|
Modifier
|
||||||
.focusCard(FocusStyle.Poster)
|
.focusCard(FocusStyle.Poster)
|
||||||
.size(AppDimens.POSTER_W.dp, AppDimens.POSTER_H.dp)
|
.size(AppDimens.POSTER_W.dp, AppDimens.POSTER_H.dp)
|
||||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
.clickableWithMenu(model.watch, onClick),
|
||||||
) {
|
) {
|
||||||
RemoteImage(model.image, PosterCorner, Modifier.fillMaxSize())
|
RemoteImage(model.image, PosterCorner, Modifier.fillMaxSize())
|
||||||
if (model.isNew) NewBadge(Modifier.align(Alignment.TopStart).padding(6.dp))
|
if (model.isNew) NewBadge(Modifier.align(Alignment.TopStart).padding(6.dp))
|
||||||
@@ -120,7 +124,7 @@ fun WideCard(model: WideModel, onClick: () -> Unit, modifier: Modifier = Modifie
|
|||||||
modifier
|
modifier
|
||||||
.focusCard(FocusStyle.Wide)
|
.focusCard(FocusStyle.Wide)
|
||||||
.size(AppDimens.WIDE_W.dp, AppDimens.WIDE_H.dp)
|
.size(AppDimens.WIDE_W.dp, AppDimens.WIDE_H.dp)
|
||||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
.clickableWithMenu(model.watch, onClick),
|
||||||
) {
|
) {
|
||||||
RemoteImage(model.image, WideCorner, Modifier.fillMaxSize(), shade = WideShade)
|
RemoteImage(model.image, WideCorner, Modifier.fillMaxSize(), shade = WideShade)
|
||||||
Column(Modifier.align(Alignment.BottomStart).padding(start = 10.dp, end = 10.dp, bottom = 11.dp)) {
|
Column(Modifier.align(Alignment.BottomStart).padding(start = 10.dp, end = 10.dp, bottom = 11.dp)) {
|
||||||
@@ -150,6 +154,7 @@ data class FeaturedModel(
|
|||||||
val image: RemoteImageSpec?,
|
val image: RemoteImageSpec?,
|
||||||
val progress: Float,
|
val progress: Float,
|
||||||
val remaining: String?,
|
val remaining: String?,
|
||||||
|
val watch: WatchTarget? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -158,7 +163,7 @@ fun FeaturedCard(model: FeaturedModel, onClick: () -> Unit, modifier: Modifier =
|
|||||||
modifier
|
modifier
|
||||||
.focusCard(FocusStyle.Featured)
|
.focusCard(FocusStyle.Featured)
|
||||||
.size(410.dp, 230.5.dp)
|
.size(410.dp, 230.5.dp)
|
||||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
.clickableWithMenu(model.watch, onClick),
|
||||||
) {
|
) {
|
||||||
RemoteImage(model.image, FeaturedCorner, Modifier.fillMaxSize(), shade = FeaturedShade)
|
RemoteImage(model.image, FeaturedCorner, Modifier.fillMaxSize(), shade = FeaturedShade)
|
||||||
Column(Modifier.align(Alignment.BottomStart).padding(start = 20.dp, end = 20.dp, bottom = 22.dp)) {
|
Column(Modifier.align(Alignment.BottomStart).padding(start = 20.dp, end = 20.dp, bottom = 22.dp)) {
|
||||||
|
|||||||
348
app/src/main/kotlin/ru/stenfredd/movies/ui/menu/WatchMenu.kt
Normal file
348
app/src/main/kotlin/ru/stenfredd/movies/ui/menu/WatchMenu.kt
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
package ru.stenfredd.movies.ui.menu
|
||||||
|
|
||||||
|
import android.view.KeyEvent as AndroidKeyEvent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.nativeKeyCode
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.stenfredd.movies.core.data.WatchRepository
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemKind
|
||||||
|
import ru.stenfredd.movies.ui.components.AppIcon
|
||||||
|
import ru.stenfredd.movies.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.movies.ui.components.Icon
|
||||||
|
import ru.stenfredd.movies.ui.components.focusCard
|
||||||
|
import ru.stenfredd.movies.ui.model.plural
|
||||||
|
import ru.stenfredd.movies.ui.player.SidePanel
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppType
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** What the watched menu marks: a movie, an episode, or every episode of a season or a series. */
|
||||||
|
@Immutable
|
||||||
|
data class WatchTarget(
|
||||||
|
val id: UUID,
|
||||||
|
val kind: ItemKind,
|
||||||
|
/** The movie or series name. */
|
||||||
|
val title: String,
|
||||||
|
/** Which part of the series: "S1 · E4 · Пилот", "Сезон 2"; the year of a movie. */
|
||||||
|
val subtitle: String? = null,
|
||||||
|
/** Watched; of a season or series — every episode. */
|
||||||
|
val played: Boolean,
|
||||||
|
/** A started movie or episode, 0..1. */
|
||||||
|
val progress: Float? = null,
|
||||||
|
/** Episodes of a season or series and how many are watched: known on the series page. */
|
||||||
|
val episodes: Int? = null,
|
||||||
|
val watchedEpisodes: Int? = null,
|
||||||
|
/** Some episode of a season or series is started (has a place to continue from). */
|
||||||
|
val started: Boolean = false,
|
||||||
|
/** Unwatched episodes of a season or series, from a card (the server doesn't tell watched ones there). */
|
||||||
|
val unplayed: Int? = null,
|
||||||
|
) {
|
||||||
|
val isGroup: Boolean get() = kind == ItemKind.Series || kind == ItemKind.Season
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the watched menu; provided by [WatchMenuHost] above every screen. */
|
||||||
|
@Stable
|
||||||
|
class WatchMenu internal constructor() {
|
||||||
|
internal var shown: Shown? by mutableStateOf(null)
|
||||||
|
|
||||||
|
/** Shows the menu for [target]; focus goes back to [returnTo] when it closes. */
|
||||||
|
fun open(target: WatchTarget, returnTo: FocusRequester) {
|
||||||
|
shown = Shown(target, returnTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One opening of the menu (a class: a reply that comes late must not close the next one). */
|
||||||
|
internal class Shown(val target: WatchTarget, val returnTo: FocusRequester)
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalWatchMenu = staticCompositionLocalOf<WatchMenu?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click of a card or chip that also opens the watched menu for [target]: OK held down (a long press), or the remote's
|
||||||
|
* Menu key where it has one. Without a target it's a plain click. Put it where `clickable` would go.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.clickableWithMenu(target: WatchTarget?, onClick: () -> Unit): Modifier {
|
||||||
|
val menu = LocalWatchMenu.current
|
||||||
|
if (target == null || menu == null) return clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
val self = remember { FocusRequester() }
|
||||||
|
val open = remember(menu, target) { { menu.open(target, self) } }
|
||||||
|
return this
|
||||||
|
.focusRequester(self)
|
||||||
|
.onKeyEvent { e ->
|
||||||
|
(e.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_MENU).also { if (it && e.type == KeyEventType.KeyUp) open() }
|
||||||
|
}
|
||||||
|
.combinedClickable(interactionSource = null, indication = null, onLongClick = open, onClick = onClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class WatchMenuViewModel @Inject constructor(private val watch: WatchRepository) : ViewModel() {
|
||||||
|
/** Screens reload on their own when it's done (the cache tells them the user data changed). */
|
||||||
|
suspend fun setPlayed(id: UUID, played: Boolean) = watch.setPlayed(id, played)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts the watched menu over [content]: a panel on the right, opaque (a full-screen scrim is expensive on weak TV
|
||||||
|
* GPUs), as the player's settings panels. Marking a season or series marks every episode on the server.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WatchMenuHost(content: @Composable () -> Unit) {
|
||||||
|
val menu = remember { WatchMenu() }
|
||||||
|
val vm: WatchMenuViewModel = hiltViewModel()
|
||||||
|
// Not the panel's scope: a request goes on when the panel is closed before the server answers.
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
fun close(shown: WatchMenu.Shown) {
|
||||||
|
if (menu.shown !== shown) return
|
||||||
|
// Focus goes back before the panel goes: a focused panel leaving would hand focus to the page's first button.
|
||||||
|
val back = runCatching { shown.returnTo.requestFocus() }.getOrDefault(false)
|
||||||
|
menu.shown = null
|
||||||
|
if (!back) {
|
||||||
|
scope.launch {
|
||||||
|
withFrameNanos { }
|
||||||
|
runCatching { shown.returnTo.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalWatchMenu provides menu) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
content()
|
||||||
|
menu.shown?.let { shown ->
|
||||||
|
key(shown) {
|
||||||
|
var saving by remember { mutableStateOf<Boolean?>(null) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
WatchPanel(
|
||||||
|
shown.target,
|
||||||
|
saving = saving,
|
||||||
|
error = error,
|
||||||
|
onPick = { played ->
|
||||||
|
if (saving == null) {
|
||||||
|
saving = played
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
vm.setPlayed(shown.target.id, played)
|
||||||
|
close(shown)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Watched menu: marking failed")
|
||||||
|
saving = null
|
||||||
|
error = e.message ?: e.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClose = { close(shown) },
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MenuAction(val played: Boolean, val icon: AppIcon, val label: String, val detail: String?)
|
||||||
|
|
||||||
|
private val ActionFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))), animate = false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu itself: what the item is, how much of it is watched, and the actions. [saving] is the action being sent
|
||||||
|
* (true = watched), [error] why the last one failed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun WatchPanel(
|
||||||
|
target: WatchTarget,
|
||||||
|
saving: Boolean?,
|
||||||
|
error: String?,
|
||||||
|
onPick: (Boolean) -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
val initial = remember { FocusRequester() }
|
||||||
|
// The long press that opened the menu may still be held: its repeats and release must not press an action,
|
||||||
|
// so OK counts only from a fresh press.
|
||||||
|
val okArmed = remember { booleanArrayOf(false) }
|
||||||
|
// Back acts on release, as Android does: closing on the press would send the release to the page, which would leave it.
|
||||||
|
val backDown = remember { booleanArrayOf(false) }
|
||||||
|
SidePanel(
|
||||||
|
title = target.kindLabel(),
|
||||||
|
dot = AppColors.Green,
|
||||||
|
hint = "Назад — закрыть",
|
||||||
|
initial = initial,
|
||||||
|
modifier = modifier.onPreviewKeyEvent {
|
||||||
|
val native = it.nativeKeyEvent
|
||||||
|
when (native.keyCode) {
|
||||||
|
AndroidKeyEvent.KEYCODE_DPAD_CENTER, AndroidKeyEvent.KEYCODE_ENTER, AndroidKeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||||
|
if (it.type == KeyEventType.KeyDown && native.repeatCount == 0) okArmed[0] = true
|
||||||
|
!okArmed[0]
|
||||||
|
}
|
||||||
|
AndroidKeyEvent.KEYCODE_BACK, AndroidKeyEvent.KEYCODE_ESCAPE -> {
|
||||||
|
when (it.type) {
|
||||||
|
KeyEventType.KeyDown -> backDown[0] = true
|
||||||
|
KeyEventType.KeyUp -> if (backDown[0]) onClose()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
BasicText(target.title, style = AppType.Section.copy(fontSize = 17.sp, lineHeight = 21.sp), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
target.subtitle?.let {
|
||||||
|
BasicText(it, Modifier.padding(top = 5.dp), style = AppType.MonoMeta, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
target.status()?.let {
|
||||||
|
BasicText(it, Modifier.padding(top = 9.dp), style = AppType.Body.copy(color = if (target.played) AppColors.Green else AppColors.TextMuted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
target.actions().forEachIndexed { i, action ->
|
||||||
|
ActionRow(
|
||||||
|
action,
|
||||||
|
saving = saving == action.played,
|
||||||
|
onClick = { onPick(action.played) },
|
||||||
|
modifier = if (i == 0) Modifier.focusRequester(initial) else Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error?.let {
|
||||||
|
BasicText("Не удалось сохранить: $it", style = AppType.Body.copy(color = AppColors.Red), maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "✓ Отметить просмотренным" with what it touches under it. */
|
||||||
|
@Composable
|
||||||
|
private fun ActionRow(action: MenuAction, saving: Boolean, onClick: () -> Unit, modifier: Modifier) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(7.dp)
|
||||||
|
val tint = if (action.played) AppColors.Green else AppColors.TextSecondary
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.focusCard(ActionFocus)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(if (action.detail != null) 52.dp else 42.dp)
|
||||||
|
.background(if (focused) Color(0xFF1F2238) else AppColors.SurfaceRaised, shape)
|
||||||
|
.border(0.5.dp, if (focused) AppColors.Accent else Color(0xFF232637), shape)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||||
|
) {
|
||||||
|
Icon(action.icon, tint, Modifier.size(15.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
BasicText(
|
||||||
|
action.label,
|
||||||
|
style = AppType.BodyLarge.copy(fontWeight = FontWeight.Medium, color = AppColors.Text),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
(if (saving) "Сохраняем…" else action.detail)?.let {
|
||||||
|
BasicText(it, Modifier.padding(top = 4.dp), style = AppType.MonoCaption.copy(fontSize = 9.5.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun WatchTarget.kindLabel(): String = when (kind) {
|
||||||
|
ItemKind.Episode -> "СЕРИЯ"
|
||||||
|
ItemKind.Season -> "СЕЗОН"
|
||||||
|
ItemKind.Series -> "СЕРИАЛ"
|
||||||
|
else -> "ФИЛЬМ"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun episodesWord(n: Int) = plural(n, "серия", "серии", "серий")
|
||||||
|
|
||||||
|
/** "Просмотрено 12 из 62 серий", "Просмотрено 42%", "Не просмотрен". */
|
||||||
|
private fun WatchTarget.status(): String? {
|
||||||
|
val feminine = kind == ItemKind.Episode
|
||||||
|
if (!isGroup) {
|
||||||
|
return when {
|
||||||
|
played -> if (feminine) "Просмотрена" else "Просмотрен"
|
||||||
|
progress != null -> "Просмотрено ${(progress * 100).roundToInt()}%"
|
||||||
|
else -> if (feminine) "Не просмотрена" else "Не просмотрен"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
played -> "Все серии просмотрены"
|
||||||
|
episodes != null && watchedEpisodes != null -> when (watchedEpisodes) {
|
||||||
|
0 -> "Ни одной серии не просмотрено"
|
||||||
|
else -> "Просмотрено $watchedEpisodes из $episodes ${plural(episodes, "серии", "серий", "серий")}"
|
||||||
|
}
|
||||||
|
unplayed != null && unplayed > 0 -> "Не просмотрено: $unplayed ${episodesWord(unplayed)}"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only what changes something: "watched" unless it already is, "unwatched" if anything is watched or started.
|
||||||
|
* A card doesn't tell whether some episodes of a series are watched, so it offers both.
|
||||||
|
*/
|
||||||
|
private fun WatchTarget.actions(): List<MenuAction> = buildList {
|
||||||
|
if (!isGroup) {
|
||||||
|
val ending = if (kind == ItemKind.Episode) "ой" else "ым"
|
||||||
|
if (!played) add(MenuAction(true, AppIcon.Check, "Отметить просмотренн$ending", null))
|
||||||
|
if (played || progress != null) {
|
||||||
|
add(MenuAction(false, AppIcon.Replay, "Отметить непросмотренн$ending", if (progress != null) "Место остановки сбросится" else null))
|
||||||
|
}
|
||||||
|
return@buildList
|
||||||
|
}
|
||||||
|
val noun = if (kind == ItemKind.Season) "сезон" else "сериал"
|
||||||
|
if (!played) {
|
||||||
|
add(MenuAction(true, AppIcon.Check, "Отметить весь $noun просмотренным", episodes?.let { "$it ${episodesWord(it)}" } ?: "Все серии"))
|
||||||
|
}
|
||||||
|
if (played || started || watchedEpisodes == null || watchedEpisodes > 0) {
|
||||||
|
add(MenuAction(false, AppIcon.Replay, "Отметить весь $noun непросмотренным", "Отметки и места остановки всех серий сбросятся"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import ru.stenfredd.movies.ui.components.FeaturedModel
|
|||||||
import ru.stenfredd.movies.ui.components.PosterModel
|
import ru.stenfredd.movies.ui.components.PosterModel
|
||||||
import ru.stenfredd.movies.ui.components.RemoteImageSpec
|
import ru.stenfredd.movies.ui.components.RemoteImageSpec
|
||||||
import ru.stenfredd.movies.ui.components.WideModel
|
import ru.stenfredd.movies.ui.components.WideModel
|
||||||
|
import ru.stenfredd.movies.ui.menu.WatchTarget
|
||||||
import ru.stenfredd.movies.ui.theme.AppDimens
|
import ru.stenfredd.movies.ui.theme.AppDimens
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -45,6 +46,7 @@ class CardMapper @Inject constructor(
|
|||||||
progress = item.user.progress,
|
progress = item.user.progress,
|
||||||
isNew = isNew(item),
|
isNew = isNew(item),
|
||||||
watched = item.user.played,
|
watched = item.user.played,
|
||||||
|
watch = watchTarget(item),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ class CardMapper @Inject constructor(
|
|||||||
subtitle = listOfNotNull(item.episodeLabel() ?: item.year?.toString(), item.remaining()).joinToString(" · "),
|
subtitle = listOfNotNull(item.episodeLabel() ?: item.year?.toString(), item.remaining()).joinToString(" · "),
|
||||||
image = image(item.landscape(), AppDimens.WIDE_W.toFloat(), AppDimens.WIDE_H.toFloat()),
|
image = image(item.landscape(), AppDimens.WIDE_W.toFloat(), AppDimens.WIDE_H.toFloat()),
|
||||||
progress = item.user.progress,
|
progress = item.user.progress,
|
||||||
|
watch = watchTarget(item),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun featured(item: ItemSummary, row: String): FeaturedModel {
|
fun featured(item: ItemSummary, row: String): FeaturedModel {
|
||||||
@@ -83,6 +86,7 @@ class CardMapper @Inject constructor(
|
|||||||
image = image(item.backdrop ?: item.landscape(), FEATURED_W, FEATURED_H),
|
image = image(item.backdrop ?: item.landscape(), FEATURED_W, FEATURED_H),
|
||||||
progress = item.user.progress ?: 0f,
|
progress = item.user.progress ?: 0f,
|
||||||
remaining = item.remaining(),
|
remaining = item.remaining(),
|
||||||
|
watch = watchTarget(item),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,18 +102,34 @@ class CardMapper @Inject constructor(
|
|||||||
else -> Screen.Detail(item.id)
|
else -> Screen.Detail(item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the card's long press marks watched: the item itself (a single new episode too, though it shows its series). */
|
||||||
|
fun watchTarget(item: ItemSummary): WatchTarget? {
|
||||||
|
val part = item.kind == ItemKind.Episode || item.kind == ItemKind.Season
|
||||||
|
return when (item.kind) {
|
||||||
|
ItemKind.Movie, ItemKind.Video, ItemKind.Episode, ItemKind.Season, ItemKind.Series -> WatchTarget(
|
||||||
|
id = item.id,
|
||||||
|
kind = item.kind,
|
||||||
|
title = (if (part) item.seriesName else null) ?: item.name,
|
||||||
|
subtitle = when (item.kind) {
|
||||||
|
ItemKind.Episode -> listOfNotNull(
|
||||||
|
item.seasonNumber?.let { "S$it" },
|
||||||
|
item.episodeNumber?.let { "E$it" },
|
||||||
|
item.name.takeIf { item.seriesName != null },
|
||||||
|
).joinToString(" · ").ifEmpty { null }
|
||||||
|
ItemKind.Season -> item.name.takeIf { item.seriesName != null }
|
||||||
|
else -> item.year?.toString()
|
||||||
|
},
|
||||||
|
played = item.user.played,
|
||||||
|
progress = item.user.progress,
|
||||||
|
unplayed = item.user.unplayedCount.takeIf { item.kind == ItemKind.Series || item.kind == ItemKind.Season },
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun image(ref: ImageRef?, wDp: Float, hDp: Float): RemoteImageSpec? =
|
fun image(ref: ImageRef?, wDp: Float, hDp: Float): RemoteImageSpec? =
|
||||||
ref?.let { RemoteImageSpec(images.url(it, px(wDp), px(hDp)), it.blurHash) }
|
ref?.let { RemoteImageSpec(images.url(it, px(wDp), px(hDp)), it.blurHash) }
|
||||||
|
|
||||||
/** Episode still, else the backdrop or thumb of the item or its series. */
|
|
||||||
private fun ItemSummary.landscape(): ImageRef? = (if (kind == ItemKind.Episode) primary else null) ?: backdrop ?: thumb
|
|
||||||
|
|
||||||
private fun ItemSummary.remaining(): String? {
|
|
||||||
val runtime = runtimeMs ?: return null
|
|
||||||
val left = runtime * (1 - (user.progress ?: 0f)) / 60_000
|
|
||||||
return "осталось ${left.roundToInt()} мин"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ItemSummary.episodeLabel(): String? =
|
private fun ItemSummary.episodeLabel(): String? =
|
||||||
if (kind == ItemKind.Episode && seasonNumber != null && episodeNumber != null) "S$seasonNumber · E$episodeNumber · $name" else null
|
if (kind == ItemKind.Episode && seasonNumber != null && episodeNumber != null) "S$seasonNumber · E$episodeNumber · $name" else null
|
||||||
|
|
||||||
@@ -133,3 +153,13 @@ class CardMapper @Inject constructor(
|
|||||||
const val FEATURED_H = 230.5f
|
const val FEATURED_H = 230.5f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Episode still, else the backdrop or thumb of the item or its series. Also the TV home screen's cards (`LauncherFeed`). */
|
||||||
|
fun ItemSummary.landscape(): ImageRef? = (if (kind == ItemKind.Episode) primary else null) ?: backdrop ?: thumb
|
||||||
|
|
||||||
|
/** "осталось 31 мин" from the item's progress. */
|
||||||
|
fun ItemSummary.remaining(): String? {
|
||||||
|
val runtime = runtimeMs ?: return null
|
||||||
|
val left = runtime * (1 - (user.progress ?: 0f)) / 60_000
|
||||||
|
return "осталось ${left.roundToInt()} мин"
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import ru.stenfredd.movies.ui.components.focusCard
|
|||||||
import ru.stenfredd.movies.ui.theme.AppColors
|
import ru.stenfredd.movies.ui.theme.AppColors
|
||||||
import ru.stenfredd.movies.ui.theme.AppType
|
import ru.stenfredd.movies.ui.theme.AppType
|
||||||
|
|
||||||
/** The card shows this long before the end of an episode that has a next one. */
|
/** Without known credits the card shows this long before the end of an episode that has a next one. */
|
||||||
internal const val NEXT_PROMPT_MS = 30_000L
|
internal const val NEXT_PROMPT_MS = 30_000L
|
||||||
|
|
||||||
private val CardColor = Color(0xFF0E1018)
|
private val CardColor = Color(0xFF0E1018)
|
||||||
@@ -51,12 +51,13 @@ private val PromptFocus = FocusStyle(scale = 1f, corner = 17.dp, glows = FocusSt
|
|||||||
/**
|
/**
|
||||||
* "Следующая через N с" at the end of an episode: still and title of the next one, "Смотреть сейчас" and
|
* "Следующая через N с" at the end of an episode: still and title of the next one, "Смотреть сейчас" and
|
||||||
* "Досмотреть титры". Opaque (over video a translucent card costs a blend every frame); the countdown and the bar
|
* "Досмотреть титры". Opaque (over video a translucent card costs a blend every frame); the countdown and the bar
|
||||||
* move once a second, as the position does.
|
* move once a second, as the position does. [remainingMs] of [countdownMs] are left until the next episode starts.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun NextEpisodeCard(
|
internal fun NextEpisodeCard(
|
||||||
next: NextEpisode,
|
next: NextEpisode,
|
||||||
remainingMs: Long,
|
remainingMs: Long,
|
||||||
|
countdownMs: Long,
|
||||||
playNow: FocusRequester,
|
playNow: FocusRequester,
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
onStay: () -> Unit,
|
onStay: () -> Unit,
|
||||||
@@ -65,7 +66,7 @@ internal fun NextEpisodeCard(
|
|||||||
) {
|
) {
|
||||||
val shape = RoundedCornerShape(10.dp)
|
val shape = RoundedCornerShape(10.dp)
|
||||||
val seconds = ((remainingMs + 999) / 1000).coerceAtLeast(0)
|
val seconds = ((remainingMs + 999) / 1000).coerceAtLeast(0)
|
||||||
val fraction = (1f - remainingMs.toFloat() / NEXT_PROMPT_MS).coerceIn(0f, 1f)
|
val fraction = (1f - remainingMs.toFloat() / countdownMs).coerceIn(0f, 1f)
|
||||||
Column(
|
Column(
|
||||||
modifier
|
modifier
|
||||||
.width(320.dp)
|
.width(320.dp)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.em
|
import androidx.compose.ui.unit.em
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import ru.stenfredd.movies.core.data.model.MediaSegment
|
||||||
import ru.stenfredd.movies.ui.components.AppIcon
|
import ru.stenfredd.movies.ui.components.AppIcon
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import ru.stenfredd.movies.ui.components.FocusStyle
|
import ru.stenfredd.movies.ui.components.FocusStyle
|
||||||
@@ -131,11 +132,19 @@ internal fun OsdTop(label: String, title: String, subtitle: String?, endsAt: Str
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track with the buffered part, the played part and chapter marks; thicker with a knob when focused.
|
* Track with the buffered part, skippable parts (intro, credits), the played part and chapter marks; thicker with a
|
||||||
* Drawn as plain rectangles in one draw call: no layers, no paths.
|
* knob when focused. Drawn as plain rectangles in one draw call: no layers, no paths.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun Timeline(positionMs: Long, durationMs: Long, bufferedMs: Long, chaptersMs: List<Long>, focused: Boolean, modifier: Modifier = Modifier) {
|
internal fun Timeline(
|
||||||
|
positionMs: Long,
|
||||||
|
durationMs: Long,
|
||||||
|
bufferedMs: Long,
|
||||||
|
chaptersMs: List<Long>,
|
||||||
|
spans: List<MediaSegment>,
|
||||||
|
focused: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
Spacer(
|
Spacer(
|
||||||
modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -148,6 +157,10 @@ internal fun Timeline(positionMs: Long, durationMs: Long, bufferedMs: Long, chap
|
|||||||
fun x(ms: Long) = if (durationMs > 0) (ms.toFloat() / durationMs).coerceIn(0f, 1f) * w else 0f
|
fun x(ms: Long) = if (durationMs > 0) (ms.toFloat() / durationMs).coerceIn(0f, 1f) * w else 0f
|
||||||
drawRoundRect(Color(0x29FFFFFF), Offset(0f, top), Size(w, trackH), r)
|
drawRoundRect(Color(0x29FFFFFF), Offset(0f, top), Size(w, trackH), r)
|
||||||
if (bufferedMs > positionMs) drawRoundRect(Color(0x2EFFFFFF), Offset(0f, top), Size(x(bufferedMs), trackH), r)
|
if (bufferedMs > positionMs) drawRoundRect(Color(0x2EFFFFFF), Offset(0f, top), Size(x(bufferedMs), trackH), r)
|
||||||
|
for (span in spans) {
|
||||||
|
val from = x(span.startMs)
|
||||||
|
drawRect(SpanColor, Offset(from, top), Size(x(span.endMs) - from, trackH))
|
||||||
|
}
|
||||||
val played = x(positionMs)
|
val played = x(positionMs)
|
||||||
if (played > 0f) {
|
if (played > 0f) {
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
@@ -171,6 +184,8 @@ internal fun Timeline(positionMs: Long, durationMs: Long, bufferedMs: Long, chap
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val SpanColor = Color(0x598F6BFF)
|
||||||
|
|
||||||
/** ⟲ ⟳ ⏮ ⏭: translucent circle, light fill when focused. */
|
/** ⟲ ⟳ ⏮ ⏭: translucent circle, light fill when focused. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun OsdCircleButton(icon: AppIcon, size: Dp, iconSize: Dp, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
internal fun OsdCircleButton(icon: AppIcon, size: Dp, iconSize: Dp, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import ru.stenfredd.movies.BuildConfig
|
import ru.stenfredd.movies.BuildConfig
|
||||||
import ru.stenfredd.movies.core.data.model.MediaInfo
|
import ru.stenfredd.movies.core.data.model.MediaInfo
|
||||||
|
import ru.stenfredd.movies.core.data.model.MediaSegment
|
||||||
import ru.stenfredd.movies.core.data.model.VideoRange
|
import ru.stenfredd.movies.core.data.model.VideoRange
|
||||||
import ru.stenfredd.movies.core.data.toMediaInfo
|
import ru.stenfredd.movies.core.data.toMediaInfo
|
||||||
import ru.stenfredd.movies.playback.api.DolbyVisionSwitchable
|
import ru.stenfredd.movies.playback.api.DolbyVisionSwitchable
|
||||||
@@ -77,6 +78,8 @@ import java.util.UUID
|
|||||||
* ↓ opens the drawer ("О фильме" / episodes), ↑ shows the controls. Seeks are collected while the keys are pressed
|
* ↓ opens the drawer ("О фильме" / episodes), ↑ shows the controls. Seeks are collected while the keys are pressed
|
||||||
* and sent to the engine once they stop. The controls hide after 6 s of no keys while playing.
|
* and sent to the engine once they stop. The controls hide after 6 s of no keys while playing.
|
||||||
* Back: panel → drawer → controls → leave the player.
|
* Back: panel → drawer → controls → leave the player.
|
||||||
|
* Segments from the server (intro, credits …) get "Пропустить …" or are skipped on their own, as set in the settings;
|
||||||
|
* known credits bring the "next episode" card at their start.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerScreen(
|
fun PlayerScreen(
|
||||||
@@ -130,6 +133,21 @@ private class PlayerUiState {
|
|||||||
var nextDismissedFor by mutableStateOf<UUID?>(null)
|
var nextDismissedFor by mutableStateOf<UUID?>(null)
|
||||||
/** The "next episode" card has focus: arrows and OK go to its buttons. */
|
/** The "next episode" card has focus: arrows and OK go to its buttons. */
|
||||||
var promptFocused = false
|
var promptFocused = false
|
||||||
|
/** Where the "next episode" card appeared over known credits: its countdown runs from there. */
|
||||||
|
var nextFrom by mutableStateOf<Long?>(null)
|
||||||
|
/** Segments skipped in this playback (by the button or on their own): not offered again. */
|
||||||
|
var skipped by mutableStateOf(SegmentSet())
|
||||||
|
/** Segments the user seeked into: they went there on purpose, so these are not skipped on their own. */
|
||||||
|
var seekedInto by mutableStateOf(SegmentSet())
|
||||||
|
/** Segments whose button timed out over the bare video: it shows only with the controls. */
|
||||||
|
var skipHidden by mutableStateOf(SegmentSet())
|
||||||
|
/** The skip button has focus: OK goes to it. */
|
||||||
|
var skipFocused = false
|
||||||
|
/** Something in the player has focus (a disappearing button can leave focus nowhere). */
|
||||||
|
var hasFocus = false
|
||||||
|
/** "Заставка пропущена" after an automatic skip. */
|
||||||
|
var notice by mutableStateOf<String?>(null)
|
||||||
|
var noticeVersion by mutableIntStateOf(0)
|
||||||
|
|
||||||
val root = FocusRequester()
|
val root = FocusRequester()
|
||||||
val playPause = FocusRequester()
|
val playPause = FocusRequester()
|
||||||
@@ -138,6 +156,7 @@ private class PlayerUiState {
|
|||||||
val drawerInitial = FocusRequester()
|
val drawerInitial = FocusRequester()
|
||||||
val errorButton = FocusRequester()
|
val errorButton = FocusRequester()
|
||||||
val nextPlay = FocusRequester()
|
val nextPlay = FocusRequester()
|
||||||
|
val skipButton = FocusRequester()
|
||||||
val debugMenuFocus = FocusRequester()
|
val debugMenuFocus = FocusRequester()
|
||||||
val chips = PlayerPanel.entries.associateWith { FocusRequester() }
|
val chips = PlayerPanel.entries.associateWith { FocusRequester() }
|
||||||
var panelFrom: FocusRequester? = null
|
var panelFrom: FocusRequester? = null
|
||||||
@@ -171,6 +190,8 @@ private fun PlayerUi(
|
|||||||
val info by vm.info.collectAsStateWithLifecycle()
|
val info by vm.info.collectAsStateWithLifecycle()
|
||||||
val season by vm.season.collectAsStateWithLifecycle()
|
val season by vm.season.collectAsStateWithLifecycle()
|
||||||
val style by vm.subtitleStyle.collectAsStateWithLifecycle()
|
val style by vm.subtitleStyle.collectAsStateWithLifecycle()
|
||||||
|
val segments by vm.segments.collectAsStateWithLifecycle()
|
||||||
|
val defaults by vm.defaults.collectAsStateWithLifecycle()
|
||||||
val ui = remember { PlayerUiState() }
|
val ui = remember { PlayerUiState() }
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val media = remember(state.source) { state.source?.toMediaInfo() }
|
val media = remember(state.source) { state.source?.toMediaInfo() }
|
||||||
@@ -179,10 +200,30 @@ private fun PlayerUi(
|
|||||||
val ended = state.engineState.phase == EngineState.Phase.ENDED
|
val ended = state.engineState.phase == EngineState.Phase.ENDED
|
||||||
val shownPosition = ui.scrubTarget ?: state.positionMs
|
val shownPosition = ui.scrubTarget ?: state.positionMs
|
||||||
val remainingMs = state.durationMs - state.positionMs
|
val remainingMs = state.durationMs - state.positionMs
|
||||||
// "Следующая через N с" near the end of an episode, unless dismissed or something is open over the video.
|
val itemId = state.itemId
|
||||||
val next = season?.nextEpisode?.takeIf {
|
val nextEpisode = season?.takeIf { it.currentId == itemId && info?.isEpisode == true }?.nextEpisode
|
||||||
info?.isEpisode == true && state.durationMs > 0 && remainingMs in 0..NEXT_PROMPT_MS && !ended && state.error == null &&
|
val plan = remember(segments, itemId, state.durationMs, defaults, nextEpisode != null) {
|
||||||
ui.nextDismissedFor != state.itemId && ui.panel == null && !ui.drawer
|
segments?.takeIf { it.itemId == itemId }?.let { SkipPlan.of(it.segments, state.durationMs, defaults, hasNext = nextEpisode != null) }
|
||||||
|
}
|
||||||
|
val credits = plan?.credits
|
||||||
|
val overVideo = !ended && state.error == null && ui.panel == null && !ui.drawer
|
||||||
|
val nextDismissed = ui.nextDismissedFor == itemId
|
||||||
|
// Known credits with "Сразу": the next episode starts with them, unless the user seeked into them.
|
||||||
|
val creditsAuto = credits != null && plan?.creditsAuto == true && credits !in ui.seekedInto.of(itemId) && !nextDismissed
|
||||||
|
// "Следующая через N с" when the credits start (or near the end), unless dismissed or something is open over the video.
|
||||||
|
val nextWindow = if (credits != null) shownPosition >= credits.startMs else remainingMs in 0..NEXT_PROMPT_MS
|
||||||
|
val next = nextEpisode?.takeIf { state.durationMs > 0 && remainingMs >= 0 && nextWindow && overVideo && !nextDismissed && !creditsAuto }
|
||||||
|
// Its countdown: to the end of the file, or over known credits from where it appeared.
|
||||||
|
val nextCountdownMs = if (credits != null) CREDITS_COUNTDOWN_MS else NEXT_PROMPT_MS
|
||||||
|
val nextLeftMs = if (credits != null) minOf((ui.nextFrom ?: state.positionMs) + CREDITS_COUNTDOWN_MS - state.positionMs, remainingMs) else remainingMs
|
||||||
|
// "Пропустить заставку" while in a skippable part. A part skipped on its own shows the button only when seeked into.
|
||||||
|
val skip = plan?.skipAt(shownPosition)?.takeIf { s ->
|
||||||
|
overVideo && !state.loading && next == null && s.segment !in ui.skipped.of(itemId) &&
|
||||||
|
(!s.auto || s.segment in ui.seekedInto.of(itemId)) && (ui.osd || s.segment !in ui.skipHidden.of(itemId))
|
||||||
|
}
|
||||||
|
val autoSkip = plan?.skipAt(state.positionMs)?.takeIf { s ->
|
||||||
|
s.auto && ui.scrubTarget == null && !state.loading && state.error == null && !ended &&
|
||||||
|
s.segment !in ui.skipped.of(itemId) && s.segment !in ui.seekedInto.of(itemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hideOsd() {
|
fun hideOsd() {
|
||||||
@@ -192,6 +233,15 @@ private fun PlayerUi(
|
|||||||
if (next != null) ui.focus(ui.nextPlay)
|
if (next != null) ui.focus(ui.nextPlay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun skipPart(part: Skippable, auto: Boolean) {
|
||||||
|
ui.skipped = ui.skipped.plus(itemId, part.segment)
|
||||||
|
session.seekTo(part.segment.endMs)
|
||||||
|
if (auto) {
|
||||||
|
ui.notice = SkipLabels.skipped(part.segment.kind)
|
||||||
|
ui.noticeVersion++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun showOsd(focus: FocusRequester) {
|
fun showOsd(focus: FocusRequester) {
|
||||||
ui.osd = true
|
ui.osd = true
|
||||||
ui.focus(focus)
|
ui.focus(focus)
|
||||||
@@ -243,6 +293,7 @@ private fun PlayerUi(
|
|||||||
ui.nextDismissedFor = state.itemId
|
ui.nextDismissedFor = state.itemId
|
||||||
ui.focus(ui.root)
|
ui.focus(ui.root)
|
||||||
}
|
}
|
||||||
|
skip != null -> ui.skipHidden = ui.skipHidden.plus(itemId, skip.segment)
|
||||||
else -> onExit()
|
else -> onExit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,7 +328,7 @@ private fun PlayerUi(
|
|||||||
if (!focusManager.moveFocus(FocusDirection.Up) && repeat == 0) hideOsd()
|
if (!focusManager.moveFocus(FocusDirection.Up) && repeat == 0) hideOsd()
|
||||||
}
|
}
|
||||||
AndroidKeyEvent.KEYCODE_DPAD_UP if !ui.osd && repeat > 0 && tour == null && next == null -> Unit
|
AndroidKeyEvent.KEYCODE_DPAD_UP if !ui.osd && repeat > 0 && tour == null && next == null -> Unit
|
||||||
else -> return onNavigationKey(code, repeat, ui, session, tour, ::scrub, ::showOsd, ::openDrawer, promptShown = next != null)
|
else -> return onNavigationKey(code, repeat, ui, session, tour, ::scrub, ::showOsd, ::openDrawer, promptShown = next != null, skipShown = skip != null)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -297,6 +348,7 @@ private fun PlayerUi(
|
|||||||
LaunchedEffect(ui.scrubVersion) {
|
LaunchedEffect(ui.scrubVersion) {
|
||||||
val target = ui.scrubTarget ?: return@LaunchedEffect
|
val target = ui.scrubTarget ?: return@LaunchedEffect
|
||||||
delay(SCRUB_COMMIT_MS)
|
delay(SCRUB_COMMIT_MS)
|
||||||
|
plan?.spanAt(target)?.let { ui.seekedInto = ui.seekedInto.plus(state.itemId, it) }
|
||||||
session.seekTo(target)
|
session.seekTo(target)
|
||||||
// Keep showing the target until the engine reports the new position.
|
// Keep showing the target until the engine reports the new position.
|
||||||
delay(SCRUB_SETTLE_MS)
|
delay(SCRUB_SETTLE_MS)
|
||||||
@@ -325,13 +377,45 @@ private fun PlayerUi(
|
|||||||
LaunchedEffect(state.itemId) {
|
LaunchedEffect(state.itemId) {
|
||||||
if (ui.drawer) closeDrawer()
|
if (ui.drawer) closeDrawer()
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(autoSkip) {
|
||||||
|
autoSkip?.let { skipPart(it, auto = true) }
|
||||||
|
}
|
||||||
|
val creditsGo = creditsAuto && nextEpisode != null && credits != null && state.positionMs >= credits.startMs &&
|
||||||
|
ui.scrubTarget == null && overVideo && !state.loading
|
||||||
|
val nextCountedDown = next != null && credits != null && nextLeftMs <= 0
|
||||||
|
LaunchedEffect(creditsGo, nextCountedDown) {
|
||||||
|
if (creditsGo || nextCountedDown) nextEpisode?.let { vm.playEpisode(it.id) }
|
||||||
|
}
|
||||||
|
LaunchedEffect(ui.noticeVersion) {
|
||||||
|
if (ui.notice == null) return@LaunchedEffect
|
||||||
|
delay(SKIP_NOTICE_MS)
|
||||||
|
ui.notice = null
|
||||||
|
}
|
||||||
|
// Over the bare video the skip button takes focus and goes after a while (counted without the controls shown).
|
||||||
|
LaunchedEffect(skip?.segment, ui.osd) {
|
||||||
|
val part = skip?.segment ?: return@LaunchedEffect
|
||||||
|
if (ui.osd) return@LaunchedEffect
|
||||||
|
ui.focus(ui.skipButton)
|
||||||
|
delay(SKIP_PROMPT_MS)
|
||||||
|
ui.skipHidden = ui.skipHidden.plus(itemId, part)
|
||||||
|
}
|
||||||
|
val skipShown = skip != null
|
||||||
|
LaunchedEffect(skipShown) {
|
||||||
|
if (skipShown) return@LaunchedEffect
|
||||||
|
withFrameNanos { }
|
||||||
|
if (ui.skipFocused || !ui.hasFocus) {
|
||||||
|
ui.skipFocused = false
|
||||||
|
if (next == null) ui.focus(if (ui.osd) ui.playPause else ui.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
// The card takes focus when it appears over the bare video; with the controls shown they keep it.
|
// The card takes focus when it appears over the bare video; with the controls shown they keep it.
|
||||||
val nextShown = next != null
|
val nextShown = next != null
|
||||||
LaunchedEffect(nextShown) {
|
LaunchedEffect(nextShown) {
|
||||||
if (nextShown && !ui.osd) ui.focus(ui.nextPlay)
|
if (nextShown && !ui.osd) ui.focus(ui.nextPlay)
|
||||||
if (!nextShown) ui.promptFocused = false
|
if (!nextShown) ui.promptFocused = false
|
||||||
|
ui.nextFrom = if (nextShown) state.positionMs else null
|
||||||
}
|
}
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { session.pause() }
|
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { session.pauseInBackground() }
|
||||||
|
|
||||||
// Back while nothing in the player has focus (the key handler above doesn't see it then).
|
// Back while nothing in the player has focus (the key handler above doesn't see it then).
|
||||||
BackHandler { back() }
|
BackHandler { back() }
|
||||||
@@ -341,6 +425,7 @@ private fun PlayerUi(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.focusRequester(ui.root)
|
.focusRequester(ui.root)
|
||||||
.onPreviewKeyEvent(::onKey)
|
.onPreviewKeyEvent(::onKey)
|
||||||
|
.onFocusChanged { ui.hasFocus = it.hasFocus }
|
||||||
.focusable(),
|
.focusable(),
|
||||||
) {
|
) {
|
||||||
if (!ui.osd && !playing && !state.loading && state.error == null && !ended) PausedBadge(Modifier.align(Alignment.Center))
|
if (!ui.osd && !playing && !state.loading && state.error == null && !ended) PausedBadge(Modifier.align(Alignment.Center))
|
||||||
@@ -352,6 +437,7 @@ private fun PlayerUi(
|
|||||||
media = media,
|
media = media,
|
||||||
ui = ui,
|
ui = ui,
|
||||||
positionMs = shownPosition,
|
positionMs = shownPosition,
|
||||||
|
spans = plan?.spans.orEmpty(),
|
||||||
onToggle = session::togglePlayPause,
|
onToggle = session::togglePlayPause,
|
||||||
onSeek = { scrub(it, 0) },
|
onSeek = { scrub(it, 0) },
|
||||||
onEpisode = vm::playEpisode,
|
onEpisode = vm::playEpisode,
|
||||||
@@ -387,10 +473,29 @@ private fun PlayerUi(
|
|||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Prompts sit in the corner, above the controls while they are shown.
|
||||||
|
val promptBottom = if (ui.osd) PROMPT_OVER_OSD.dp else 32.dp
|
||||||
|
skip?.let { part ->
|
||||||
|
SkipButton(
|
||||||
|
label = SkipLabels.button(part.segment.kind),
|
||||||
|
left = PlayerLabels.time(part.segment.endMs - shownPosition),
|
||||||
|
focusRequester = ui.skipButton,
|
||||||
|
onClick = {
|
||||||
|
skipPart(part, auto = false)
|
||||||
|
ui.focus(if (ui.osd) ui.playPause else ui.root)
|
||||||
|
},
|
||||||
|
onFocus = { ui.skipFocused = it },
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = promptBottom),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ui.notice?.takeIf { skip == null && next == null }?.let { text ->
|
||||||
|
SkipNotice(text, Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = promptBottom))
|
||||||
|
}
|
||||||
next?.let { n ->
|
next?.let { n ->
|
||||||
NextEpisodeCard(
|
NextEpisodeCard(
|
||||||
n,
|
n,
|
||||||
remainingMs = remainingMs,
|
remainingMs = nextLeftMs,
|
||||||
|
countdownMs = nextCountdownMs,
|
||||||
playNow = ui.nextPlay,
|
playNow = ui.nextPlay,
|
||||||
onPlay = {
|
onPlay = {
|
||||||
hideOsd()
|
hideOsd()
|
||||||
@@ -402,8 +507,7 @@ private fun PlayerUi(
|
|||||||
ui.focus(if (ui.osd) ui.playPause else ui.root)
|
ui.focus(if (ui.osd) ui.playPause else ui.root)
|
||||||
},
|
},
|
||||||
onFocus = { ui.promptFocused = it },
|
onFocus = { ui.promptFocused = it },
|
||||||
// Above the controls while they are shown.
|
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = promptBottom),
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = if (ui.osd) 132.dp else 32.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (state.loading && state.error == null) LoadingBadge(Modifier.align(Alignment.Center))
|
if (state.loading && state.error == null) LoadingBadge(Modifier.align(Alignment.Center))
|
||||||
@@ -443,6 +547,7 @@ private fun onNavigationKey(
|
|||||||
showOsd: (FocusRequester) -> Unit,
|
showOsd: (FocusRequester) -> Unit,
|
||||||
openDrawer: () -> Unit,
|
openDrawer: () -> Unit,
|
||||||
promptShown: Boolean,
|
promptShown: Boolean,
|
||||||
|
skipShown: Boolean,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (BuildConfig.DEBUG && !ui.debugMenu) {
|
if (BuildConfig.DEBUG && !ui.debugMenu) {
|
||||||
when (code) {
|
when (code) {
|
||||||
@@ -460,6 +565,8 @@ private fun onNavigationKey(
|
|||||||
if (ui.panel != null || ui.drawer || ui.debugMenu) return false
|
if (ui.panel != null || ui.drawer || ui.debugMenu) return false
|
||||||
// The "next episode" card over the bare video: ←/→ and OK work its buttons.
|
// The "next episode" card over the bare video: ←/→ and OK work its buttons.
|
||||||
if (!ui.osd && promptShown && ui.promptFocused && code in PROMPT_KEYS) return false
|
if (!ui.osd && promptShown && ui.promptFocused && code in PROMPT_KEYS) return false
|
||||||
|
// The skip button over the bare video: OK presses it, the arrows work as usual.
|
||||||
|
if (!ui.osd && skipShown && ui.skipFocused && code in OK_KEYS) return false
|
||||||
if (!ui.osd) {
|
if (!ui.osd) {
|
||||||
when (code) {
|
when (code) {
|
||||||
AndroidKeyEvent.KEYCODE_DPAD_UP if tour != null -> {
|
AndroidKeyEvent.KEYCODE_DPAD_UP if tour != null -> {
|
||||||
@@ -505,6 +612,7 @@ private fun OsdLayer(
|
|||||||
media: MediaInfo?,
|
media: MediaInfo?,
|
||||||
ui: PlayerUiState,
|
ui: PlayerUiState,
|
||||||
positionMs: Long,
|
positionMs: Long,
|
||||||
|
spans: List<MediaSegment>,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onSeek: (Int) -> Unit,
|
onSeek: (Int) -> Unit,
|
||||||
onEpisode: (UUID) -> Unit,
|
onEpisode: (UUID) -> Unit,
|
||||||
@@ -542,6 +650,7 @@ private fun OsdLayer(
|
|||||||
durationMs = state.durationMs,
|
durationMs = state.durationMs,
|
||||||
bufferedMs = state.bufferedMs,
|
bufferedMs = state.bufferedMs,
|
||||||
chaptersMs = info?.chaptersMs.orEmpty(),
|
chaptersMs = info?.chaptersMs.orEmpty(),
|
||||||
|
spans = spans,
|
||||||
focused = ui.timelineFocused,
|
focused = ui.timelineFocused,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 3.dp)
|
.padding(top = 3.dp)
|
||||||
@@ -706,15 +815,13 @@ private fun PanelLayer(
|
|||||||
|
|
||||||
private val GRAPHIC_SUBTITLES = setOf("pgssub", "hdmv_pgs_subtitle", "dvdsub", "dvd_subtitle", "vobsub", "dvbsub")
|
private val GRAPHIC_SUBTITLES = setOf("pgssub", "hdmv_pgs_subtitle", "dvdsub", "dvd_subtitle", "vobsub", "dvbsub")
|
||||||
|
|
||||||
private val PROMPT_KEYS = setOf(
|
private val OK_KEYS = setOf(AndroidKeyEvent.KEYCODE_DPAD_CENTER, AndroidKeyEvent.KEYCODE_ENTER, AndroidKeyEvent.KEYCODE_NUMPAD_ENTER)
|
||||||
AndroidKeyEvent.KEYCODE_DPAD_LEFT,
|
|
||||||
AndroidKeyEvent.KEYCODE_DPAD_RIGHT,
|
private val PROMPT_KEYS = OK_KEYS + setOf(AndroidKeyEvent.KEYCODE_DPAD_LEFT, AndroidKeyEvent.KEYCODE_DPAD_RIGHT)
|
||||||
AndroidKeyEvent.KEYCODE_DPAD_CENTER,
|
|
||||||
AndroidKeyEvent.KEYCODE_ENTER,
|
|
||||||
AndroidKeyEvent.KEYCODE_NUMPAD_ENTER,
|
|
||||||
)
|
|
||||||
|
|
||||||
private const val OSD_TIMEOUT_MS = 6_000L
|
private const val OSD_TIMEOUT_MS = 6_000L
|
||||||
|
/** Bottom of the skip button and the "next episode" card while the controls are shown: clear of the time labels. */
|
||||||
|
private const val PROMPT_OVER_OSD = 150
|
||||||
private const val TOP_SHADE = 105
|
private const val TOP_SHADE = 105
|
||||||
private const val BOTTOM_SHADE = 170
|
private const val BOTTOM_SHADE = 170
|
||||||
private const val SCRUB_COMMIT_MS = 600L
|
private const val SCRUB_COMMIT_MS = 600L
|
||||||
|
|||||||
176
app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerSkip.kt
Normal file
176
app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerSkip.kt
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package ru.stenfredd.movies.ui.player
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import ru.stenfredd.movies.core.data.model.MediaSegment
|
||||||
|
import ru.stenfredd.movies.core.data.model.SegmentKind
|
||||||
|
import ru.stenfredd.movies.playback.core.PlaybackDefaults
|
||||||
|
import ru.stenfredd.movies.playback.core.SkipMode
|
||||||
|
import ru.stenfredd.movies.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.movies.ui.components.focusCard
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppType
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** The server's segments of one item (they arrive after the item starts playing). */
|
||||||
|
@Immutable
|
||||||
|
data class PlayerSegments(val itemId: UUID, val segments: List<MediaSegment>)
|
||||||
|
|
||||||
|
/** A segment the player offers to skip ("Пропустить заставку") or skips on its own ([auto]). */
|
||||||
|
@Immutable
|
||||||
|
internal data class Skippable(val segment: MediaSegment, val auto: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the player does with an item's segments under the settings.
|
||||||
|
* [credits]: the credits of an episode with a next one, from their start to the end of the file: the "next episode"
|
||||||
|
* card shows from their start instead of 30 s before the end ([creditsAuto]: the next episode starts right away).
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
internal data class SkipPlan(val skips: List<Skippable>, val credits: MediaSegment?, val creditsAuto: Boolean) {
|
||||||
|
/** Parts drawn on the timeline. */
|
||||||
|
val spans: List<MediaSegment> = skips.map { it.segment } + listOfNotNull(credits)
|
||||||
|
|
||||||
|
/** The skippable part [positionMs] is in (not counting its last second: nothing left to skip there). */
|
||||||
|
fun skipAt(positionMs: Long): Skippable? = skips.firstOrNull { positionMs >= it.segment.startMs && positionMs < it.segment.endMs - SKIP_END_MARGIN_MS }
|
||||||
|
|
||||||
|
/** The part a seek to [positionMs] lands in: such a part is not skipped on its own, the user went there. */
|
||||||
|
fun spanAt(positionMs: Long): MediaSegment? = spans.firstOrNull { positionMs >= it.startMs && positionMs < it.endMs }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Credits (and previews after them) that run to the end of the file count as the end of the episode: with a
|
||||||
|
* next episode they bring the "next" card, without one (a movie, the last episode) there is nothing to skip to.
|
||||||
|
* Credits followed by more of the episode (an epilogue) get "Пропустить титры".
|
||||||
|
*/
|
||||||
|
fun of(segments: List<MediaSegment>, durationMs: Long, defaults: PlaybackDefaults, hasNext: Boolean): SkipPlan? {
|
||||||
|
if (durationMs <= 0) return null
|
||||||
|
val useful = segments.filter { it.endMs - it.startMs >= MIN_SEGMENT_MS && it.startMs < durationMs }
|
||||||
|
var tail: MediaSegment? = null
|
||||||
|
for (s in useful.filter { it.kind == SegmentKind.Outro || it.kind == SegmentKind.Preview }.sortedByDescending { it.startMs }) {
|
||||||
|
val reaches = tail?.let { s.endMs >= it.startMs - TAIL_GAP_MS } ?: (s.endMs >= durationMs - TAIL_MARGIN_MS)
|
||||||
|
if (!reaches) break
|
||||||
|
tail = MediaSegment(SegmentKind.Outro, s.startMs, durationMs)
|
||||||
|
}
|
||||||
|
val tailStart = tail?.startMs ?: Long.MAX_VALUE
|
||||||
|
val skips = useful.filter { it.startMs < tailStart }.mapNotNull { s ->
|
||||||
|
val mode = if (s.kind == SegmentKind.Outro) defaults.skipCredits else defaults.skipIntro
|
||||||
|
if (mode == SkipMode.Off) null else Skippable(s, auto = mode == SkipMode.Auto)
|
||||||
|
}
|
||||||
|
val credits = tail?.takeIf { hasNext && defaults.skipCredits != SkipMode.Off }
|
||||||
|
return SkipPlan(skips, credits, creditsAuto = defaults.skipCredits == SkipMode.Auto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Segments of one item something already happened to; an item change forgets them. */
|
||||||
|
@Immutable
|
||||||
|
internal data class SegmentSet(val itemId: UUID? = null, val segments: Set<MediaSegment> = emptySet()) {
|
||||||
|
fun of(id: UUID?): Set<MediaSegment> = if (id == itemId) segments else emptySet()
|
||||||
|
|
||||||
|
fun plus(id: UUID?, segment: MediaSegment): SegmentSet = SegmentSet(id, of(id) + segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object SkipLabels {
|
||||||
|
fun button(kind: SegmentKind): String = when (kind) {
|
||||||
|
SegmentKind.Intro -> "Пропустить заставку"
|
||||||
|
SegmentKind.Recap -> "Пропустить пересказ"
|
||||||
|
SegmentKind.Preview -> "Пропустить анонс"
|
||||||
|
SegmentKind.Outro -> "Пропустить титры"
|
||||||
|
SegmentKind.Commercial -> "Пропустить рекламу"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skipped(kind: SegmentKind): String = when (kind) {
|
||||||
|
SegmentKind.Intro -> "Заставка пропущена"
|
||||||
|
SegmentKind.Recap -> "Пересказ пропущен"
|
||||||
|
SegmentKind.Preview -> "Анонс пропущен"
|
||||||
|
SegmentKind.Outro -> "Титры пропущены"
|
||||||
|
SegmentKind.Commercial -> "Реклама пропущена"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SkipColor = Color(0xFF10121C)
|
||||||
|
private val SkipBorder = Color(0x40FFFFFF)
|
||||||
|
/** `0 0 0 3px #a594ff, 0 0 40px 8px rgba(124,92,255,.65)` */
|
||||||
|
private val SkipFocus = FocusStyle(scale = 1f, corner = 20.dp, glows = listOf(FocusStyle.Glow(20.dp, 4.dp, Color(0xA67C5CFF))), animate = false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Пропустить заставку 1:12" (time left of the part) in the corner where the "next episode" card goes. Opaque, as
|
||||||
|
* that card: a translucent one over video costs a blend every frame.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SkipButton(label: String, left: String, focusRequester: FocusRequester, onClick: () -> Unit, onFocus: (Boolean) -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(20.dp)
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.focusCard(SkipFocus)
|
||||||
|
.height(40.dp)
|
||||||
|
.background(if (focused) AppColors.FocusFill else SkipColor, shape)
|
||||||
|
.border(0.5.dp, if (focused) AppColors.FocusFill else SkipBorder, shape)
|
||||||
|
.onFocusChanged {
|
||||||
|
focused = it.isFocused
|
||||||
|
onFocus(it.isFocused)
|
||||||
|
}
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 17.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
val color = if (focused) AppColors.OnFocusFill else Color.White
|
||||||
|
BasicText(label, style = AppType.Button.copy(fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = color))
|
||||||
|
BasicText(left, style = AppType.MonoMeta.copy(fontSize = 10.sp, color = color.copy(alpha = 0.7f)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Заставка пропущена" for a few seconds after an automatic skip; not focusable. */
|
||||||
|
@Composable
|
||||||
|
internal fun SkipNotice(text: String, modifier: Modifier = Modifier) {
|
||||||
|
val shape = RoundedCornerShape(16.dp)
|
||||||
|
BasicText(
|
||||||
|
text,
|
||||||
|
modifier
|
||||||
|
.background(SkipColor, shape)
|
||||||
|
.border(0.5.dp, SkipBorder, shape)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The button over the bare video hides after this long; it comes back with the controls. */
|
||||||
|
internal const val SKIP_PROMPT_MS = 10_000L
|
||||||
|
|
||||||
|
/** With known credits the "next episode" card counts down this long from their start. */
|
||||||
|
internal const val CREDITS_COUNTDOWN_MS = 15_000L
|
||||||
|
|
||||||
|
internal const val SKIP_NOTICE_MS = 3_000L
|
||||||
|
|
||||||
|
private const val SKIP_END_MARGIN_MS = 1_000L
|
||||||
|
/** Shorter segments are detection noise. */
|
||||||
|
private const val MIN_SEGMENT_MS = 2_000L
|
||||||
|
/** Credits ending this close to the end of the file run to the end (black frames after them, rounding). */
|
||||||
|
private const val TAIL_MARGIN_MS = 10_000L
|
||||||
|
/** Credits and a preview this far apart still make one tail. */
|
||||||
|
private const val TAIL_GAP_MS = 2_000L
|
||||||
@@ -24,6 +24,7 @@ import ru.stenfredd.movies.playback.api.StreamSelection
|
|||||||
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
||||||
import ru.stenfredd.movies.playback.core.DeviceCapabilitiesProbe
|
import ru.stenfredd.movies.playback.core.DeviceCapabilitiesProbe
|
||||||
import ru.stenfredd.movies.playback.core.EngineSelector
|
import ru.stenfredd.movies.playback.core.EngineSelector
|
||||||
|
import ru.stenfredd.movies.playback.core.PlaybackDefaults
|
||||||
import ru.stenfredd.movies.playback.core.PlaybackPreferences
|
import ru.stenfredd.movies.playback.core.PlaybackPreferences
|
||||||
import ru.stenfredd.movies.playback.core.PlaybackSession
|
import ru.stenfredd.movies.playback.core.PlaybackSession
|
||||||
import ru.stenfredd.movies.playback.core.PlaybackSessionFactory
|
import ru.stenfredd.movies.playback.core.PlaybackSessionFactory
|
||||||
@@ -94,6 +95,8 @@ data class PlayerInfo(
|
|||||||
/** The season of the episode being played, for the drawer and ⏮/⏭. */
|
/** The season of the episode being played, for the drawer and ⏮/⏭. */
|
||||||
@Immutable
|
@Immutable
|
||||||
data class SeasonEpisodes(
|
data class SeasonEpisodes(
|
||||||
|
/** The episode being played: until the next one's season loads, this still describes the previous one. */
|
||||||
|
val currentId: UUID,
|
||||||
/** "СЕЗОН 2 · СЕРИИ" */
|
/** "СЕЗОН 2 · СЕРИИ" */
|
||||||
val title: String,
|
val title: String,
|
||||||
/** "10 серий · 3 просмотрено" */
|
/** "10 серий · 3 просмотрено" */
|
||||||
@@ -122,10 +125,18 @@ class PlayerViewModel @javax.inject.Inject constructor(
|
|||||||
private val _season = MutableStateFlow<SeasonEpisodes?>(null)
|
private val _season = MutableStateFlow<SeasonEpisodes?>(null)
|
||||||
val season: StateFlow<SeasonEpisodes?> = _season.asStateFlow()
|
val season: StateFlow<SeasonEpisodes?> = _season.asStateFlow()
|
||||||
|
|
||||||
|
private val _segments = MutableStateFlow<PlayerSegments?>(null)
|
||||||
|
/** Intro, credits …: skip buttons and the "next episode" card. */
|
||||||
|
val segments: StateFlow<PlayerSegments?> = _segments.asStateFlow()
|
||||||
|
|
||||||
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
|
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
|
||||||
|
|
||||||
|
/** For the skip settings. */
|
||||||
|
val defaults: StateFlow<PlaybackDefaults> = prefs.defaults.stateIn(viewModelScope, SharingStarted.Eagerly, PlaybackDefaults())
|
||||||
|
|
||||||
private var startedFor: Screen.Player? = null
|
private var startedFor: Screen.Player? = null
|
||||||
private var infoJob: Job? = null
|
private var infoJob: Job? = null
|
||||||
|
private var segmentsJob: Job? = null
|
||||||
/** Quality chosen in the player: kept for the next episode. Until then the default from the settings applies. */
|
/** Quality chosen in the player: kept for the next episode. Until then the default from the settings applies. */
|
||||||
private var maxBitrate: Int? = null
|
private var maxBitrate: Int? = null
|
||||||
private var qualityChosen = false
|
private var qualityChosen = false
|
||||||
@@ -179,6 +190,7 @@ class PlayerViewModel @javax.inject.Inject constructor(
|
|||||||
override fun onCleared() = release()
|
override fun onCleared() = release()
|
||||||
|
|
||||||
private fun loadInfo(itemId: UUID) {
|
private fun loadInfo(itemId: UUID) {
|
||||||
|
loadSegments(itemId)
|
||||||
infoJob?.cancel()
|
infoJob?.cancel()
|
||||||
if (_info.value?.id != itemId) _info.value = null
|
if (_info.value?.id != itemId) _info.value = null
|
||||||
infoJob = viewModelScope.launch {
|
infoJob = viewModelScope.launch {
|
||||||
@@ -202,11 +214,27 @@ class PlayerViewModel @javax.inject.Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apart from the info: a server without segments (or failing to give them) must not cost the drawer. */
|
||||||
|
private fun loadSegments(itemId: UUID) {
|
||||||
|
segmentsJob?.cancel()
|
||||||
|
if (_segments.value?.itemId != itemId) _segments.value = null
|
||||||
|
segmentsJob = viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_segments.value = PlayerSegments(itemId, library.segments(itemId))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Player: segments failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun season(current: ItemSummary, all: List<ItemSummary>): SeasonEpisodes {
|
private fun season(current: ItemSummary, all: List<ItemSummary>): SeasonEpisodes {
|
||||||
val index = all.indexOfFirst { it.id == current.id }
|
val index = all.indexOfFirst { it.id == current.id }
|
||||||
val inSeason = all.filter { it.seasonId == current.seasonId }
|
val inSeason = all.filter { it.seasonId == current.seasonId }
|
||||||
val watched = inSeason.count { it.user.played }
|
val watched = inSeason.count { it.user.played }
|
||||||
return SeasonEpisodes(
|
return SeasonEpisodes(
|
||||||
|
currentId = current.id,
|
||||||
title = listOfNotNull(current.seasonNumber?.let { "СЕЗОН $it" }, "СЕРИИ").joinToString(" · "),
|
title = listOfNotNull(current.seasonNumber?.let { "СЕЗОН $it" }, "СЕРИИ").joinToString(" · "),
|
||||||
stat = "${inSeason.size} ${plural(inSeason.size, "серия", "серии", "серий")} · $watched просмотрено",
|
stat = "${inSeason.size} ${plural(inSeason.size, "серия", "серии", "серий")} · $watched просмотрено",
|
||||||
episodes = inSeason.map { e ->
|
episodes = inSeason.map { e ->
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ import ru.stenfredd.movies.ui.focus.Restore
|
|||||||
import ru.stenfredd.movies.ui.focus.ScreenFocus
|
import ru.stenfredd.movies.ui.focus.ScreenFocus
|
||||||
import ru.stenfredd.movies.ui.focus.rememberScreenFocus
|
import ru.stenfredd.movies.ui.focus.rememberScreenFocus
|
||||||
import ru.stenfredd.movies.ui.focus.remembered
|
import ru.stenfredd.movies.ui.focus.remembered
|
||||||
|
import ru.stenfredd.movies.ui.menu.LocalWatchMenu
|
||||||
|
import ru.stenfredd.movies.ui.menu.clickableWithMenu
|
||||||
import ru.stenfredd.movies.ui.theme.AppColors
|
import ru.stenfredd.movies.ui.theme.AppColors
|
||||||
import ru.stenfredd.movies.ui.theme.AppDimens
|
import ru.stenfredd.movies.ui.theme.AppDimens
|
||||||
import ru.stenfredd.movies.ui.theme.AppType
|
import ru.stenfredd.movies.ui.theme.AppType
|
||||||
@@ -118,6 +120,7 @@ fun SeriesScreen(
|
|||||||
|
|
||||||
private const val KEY_PLAY = "play"
|
private const val KEY_PLAY = "play"
|
||||||
private const val KEY_TRACKS = "tracks"
|
private const val KEY_TRACKS = "tracks"
|
||||||
|
private const val KEY_WATCHED = "watched"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the page highlights: the season of the chips and the map, the episode described at the bottom.
|
* What the page highlights: the season of the chips and the map, the episode described at the bottom.
|
||||||
@@ -288,6 +291,15 @@ private fun Header(
|
|||||||
Modifier.remembered(focus, "favorite"),
|
Modifier.remembered(focus, "favorite"),
|
||||||
tint = if (item.favorite) AppColors.Pink else Color(0xFFE3E5F2),
|
tint = if (item.favorite) AppColors.Pink else Color(0xFFE3E5F2),
|
||||||
)
|
)
|
||||||
|
// The whole series at once, so through the menu (it says how many episodes) rather than a toggle.
|
||||||
|
val menu = LocalWatchMenu.current
|
||||||
|
val watchedButton = remember { FocusRequester() }
|
||||||
|
CircleButton(
|
||||||
|
AppIcon.Check,
|
||||||
|
{ menu?.open(item.watch, watchedButton) },
|
||||||
|
Modifier.focusRequester(watchedButton).remembered(focus, KEY_WATCHED),
|
||||||
|
tint = if (item.watch.played) AppColors.Green else Color(0xFFE3E5F2),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -514,7 +526,7 @@ private fun SeasonChip(season: SeriesSeason, selected: Boolean, onClick: () -> U
|
|||||||
.background(if (focused) AppColors.FocusFill else if (selected) Color(0xFF211D4A) else AppColors.SurfaceRaised, shape)
|
.background(if (focused) AppColors.FocusFill else if (selected) Color(0xFF211D4A) else AppColors.SurfaceRaised, shape)
|
||||||
.border(0.5.dp, if (focused) AppColors.FocusFill else if (selected) Color(0xFF7A6CFF) else AppColors.Border, shape)
|
.border(0.5.dp, if (focused) AppColors.FocusFill else if (selected) Color(0xFF7A6CFF) else AppColors.Border, shape)
|
||||||
.onFocusChanged { focused = it.isFocused }
|
.onFocusChanged { focused = it.isFocused }
|
||||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
.clickableWithMenu(season.watch, onClick)
|
||||||
.padding(horizontal = 12.dp),
|
.padding(horizontal = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
@@ -594,7 +606,7 @@ private fun EpisodeCard(e: SeriesEpisode, onClick: () -> Unit, modifier: Modifie
|
|||||||
modifier
|
modifier
|
||||||
.focusCard(EpisodeFocus)
|
.focusCard(EpisodeFocus)
|
||||||
.size(SeriesLayout.EPISODE_W.dp, SeriesLayout.EPISODE_H.dp)
|
.size(SeriesLayout.EPISODE_W.dp, SeriesLayout.EPISODE_H.dp)
|
||||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
.clickableWithMenu(e.watch, onClick),
|
||||||
) {
|
) {
|
||||||
RemoteImage(e.image, 8.dp, Modifier.fillMaxSize())
|
RemoteImage(e.image, 8.dp, Modifier.fillMaxSize())
|
||||||
BasicText(
|
BasicText(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import ru.stenfredd.movies.core.data.LibraryRepository
|
|||||||
import ru.stenfredd.movies.core.data.SessionCache
|
import ru.stenfredd.movies.core.data.SessionCache
|
||||||
import ru.stenfredd.movies.core.data.WatchRepository
|
import ru.stenfredd.movies.core.data.WatchRepository
|
||||||
import ru.stenfredd.movies.core.data.model.ItemDetails
|
import ru.stenfredd.movies.core.data.model.ItemDetails
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemKind
|
||||||
import ru.stenfredd.movies.core.data.model.ItemSummary
|
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||||
import ru.stenfredd.movies.core.data.model.MediaInfo
|
import ru.stenfredd.movies.core.data.model.MediaInfo
|
||||||
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
||||||
@@ -34,6 +35,7 @@ import ru.stenfredd.movies.ui.components.RemoteImageSpec
|
|||||||
import ru.stenfredd.movies.ui.details.MediaLabels
|
import ru.stenfredd.movies.ui.details.MediaLabels
|
||||||
import ru.stenfredd.movies.ui.details.TrackChoices
|
import ru.stenfredd.movies.ui.details.TrackChoices
|
||||||
import ru.stenfredd.movies.ui.details.TrackOption
|
import ru.stenfredd.movies.ui.details.TrackOption
|
||||||
|
import ru.stenfredd.movies.ui.menu.WatchTarget
|
||||||
import ru.stenfredd.movies.ui.model.CardMapper
|
import ru.stenfredd.movies.ui.model.CardMapper
|
||||||
import ru.stenfredd.movies.ui.player.PlayerLabels
|
import ru.stenfredd.movies.ui.player.PlayerLabels
|
||||||
import ru.stenfredd.movies.ui.model.plural
|
import ru.stenfredd.movies.ui.model.plural
|
||||||
@@ -70,6 +72,8 @@ data class SeriesEpisode(
|
|||||||
val progress: Float?,
|
val progress: Float?,
|
||||||
val state: EpisodeState,
|
val state: EpisodeState,
|
||||||
val resumeMs: Long,
|
val resumeMs: Long,
|
||||||
|
/** What a long press on the card marks watched. */
|
||||||
|
val watch: WatchTarget,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
@@ -88,6 +92,8 @@ data class SeriesSeason(
|
|||||||
val target: Int,
|
val target: Int,
|
||||||
/** Row index of the season's divider. */
|
/** Row index of the season's divider. */
|
||||||
val dividerIndex: Int,
|
val dividerIndex: Int,
|
||||||
|
/** The whole season, for a long press on its chip. */
|
||||||
|
val watch: WatchTarget,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
@@ -103,6 +109,8 @@ data class SeriesModel(
|
|||||||
val poster: RemoteImageSpec?,
|
val poster: RemoteImageSpec?,
|
||||||
val backdropUrl: String?,
|
val backdropUrl: String?,
|
||||||
val favorite: Boolean,
|
val favorite: Boolean,
|
||||||
|
/** The whole series, for the ✓ button; played when every episode is. */
|
||||||
|
val watch: WatchTarget,
|
||||||
val seasons: List<SeriesSeason>,
|
val seasons: List<SeriesSeason>,
|
||||||
val episodes: List<SeriesEpisode>,
|
val episodes: List<SeriesEpisode>,
|
||||||
/** Index of the episode "Продолжить"/"Смотреть" plays. */
|
/** Index of the episode "Продолжить"/"Смотреть" plays. */
|
||||||
@@ -255,14 +263,15 @@ class SeriesViewModel @AssistedInject constructor(
|
|||||||
val first = episodes.size
|
val first = episodes.size
|
||||||
val dividerIndex = row++
|
val dividerIndex = row++
|
||||||
list.forEachIndexed { i, e ->
|
list.forEachIndexed { i, e ->
|
||||||
episodes += episode(e, si, row++, i, season, isNextUp = e === all.getOrNull(nextUp))
|
episodes += episode(e, si, row++, i, season, item.name, isNextUp = e === all.getOrNull(nextUp))
|
||||||
}
|
}
|
||||||
val own = episodes.subList(first, episodes.size)
|
val own = episodes.subList(first, episodes.size)
|
||||||
val watched = own.count { it.state == EpisodeState.Watched }
|
val watched = own.count { it.state == EpisodeState.Watched }
|
||||||
val number = season.seasonNumber
|
val number = season.seasonNumber
|
||||||
|
val label = if (number != null && number > 0) "Сезон $number" else season.name
|
||||||
seasons += SeriesSeason(
|
seasons += SeriesSeason(
|
||||||
key = "season/${season.id}",
|
key = "season/${season.id}",
|
||||||
label = if (number != null && number > 0) "Сезон $number" else season.name,
|
label = label,
|
||||||
stat = when {
|
stat = when {
|
||||||
watched == own.size -> "✓"
|
watched == own.size -> "✓"
|
||||||
watched == 0 && own.all { it.state == EpisodeState.New } -> "новый"
|
watched == 0 && own.all { it.state == EpisodeState.New } -> "новый"
|
||||||
@@ -277,6 +286,7 @@ class SeriesViewModel @AssistedInject constructor(
|
|||||||
?: 0
|
?: 0
|
||||||
),
|
),
|
||||||
dividerIndex = dividerIndex,
|
dividerIndex = dividerIndex,
|
||||||
|
watch = groupTarget(season.id, ItemKind.Season, item.name, label, own),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +307,7 @@ class SeriesViewModel @AssistedInject constructor(
|
|||||||
poster = cards.image(item.primary, SeriesLayout.POSTER_W, SeriesLayout.POSTER_H),
|
poster = cards.image(item.primary, SeriesLayout.POSTER_W, SeriesLayout.POSTER_H),
|
||||||
backdropUrl = item.backdrop?.let { images.url(it, cards.px(SeriesLayout.BACKDROP_W), cards.px(SeriesLayout.BACKDROP_H), quality = 80) },
|
backdropUrl = item.backdrop?.let { images.url(it, cards.px(SeriesLayout.BACKDROP_W), cards.px(SeriesLayout.BACKDROP_H), quality = 80) },
|
||||||
favorite = item.user.favorite,
|
favorite = item.user.favorite,
|
||||||
|
watch = groupTarget(item.id, ItemKind.Series, item.name, null, episodes),
|
||||||
seasons = seasons,
|
seasons = seasons,
|
||||||
episodes = episodes,
|
episodes = episodes,
|
||||||
nextUp = nextUp.coerceAtLeast(0),
|
nextUp = nextUp.coerceAtLeast(0),
|
||||||
@@ -304,7 +315,7 @@ class SeriesViewModel @AssistedInject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun episode(e: ItemSummary, season: Int, rowIndex: Int, position: Int, seasonItem: ItemSummary, isNextUp: Boolean): SeriesEpisode {
|
private fun episode(e: ItemSummary, season: Int, rowIndex: Int, position: Int, seasonItem: ItemSummary, seriesName: String, isNextUp: Boolean): SeriesEpisode {
|
||||||
val seasonNumber = e.seasonNumber ?: seasonItem.seasonNumber ?: 0
|
val seasonNumber = e.seasonNumber ?: seasonItem.seasonNumber ?: 0
|
||||||
// Some files have no episode number: their place in the season stands in.
|
// Some files have no episode number: their place in the season stands in.
|
||||||
val number = e.episodeNumber ?: (position + 1)
|
val number = e.episodeNumber ?: (position + 1)
|
||||||
@@ -335,6 +346,29 @@ class SeriesViewModel @AssistedInject constructor(
|
|||||||
progress = progress,
|
progress = progress,
|
||||||
state = state,
|
state = state,
|
||||||
resumeMs = if (progress != null) e.user.positionMs else 0,
|
resumeMs = if (progress != null) e.user.positionMs else 0,
|
||||||
|
watch = WatchTarget(
|
||||||
|
id = e.id,
|
||||||
|
kind = ItemKind.Episode,
|
||||||
|
title = seriesName,
|
||||||
|
subtitle = "S$seasonNumber · E${e.episodeNumber ?: number} · ${e.name}",
|
||||||
|
played = e.user.played,
|
||||||
|
progress = progress,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A season or the whole series: every episode of it, as the page counts them (specials too). */
|
||||||
|
private fun groupTarget(id: UUID, kind: ItemKind, title: String, subtitle: String?, episodes: List<SeriesEpisode>): WatchTarget {
|
||||||
|
val watched = episodes.count { it.state == EpisodeState.Watched }
|
||||||
|
return WatchTarget(
|
||||||
|
id = id,
|
||||||
|
kind = kind,
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle,
|
||||||
|
played = episodes.isNotEmpty() && watched == episodes.size,
|
||||||
|
episodes = episodes.size,
|
||||||
|
watchedEpisodes = watched,
|
||||||
|
started = episodes.any { it.state == EpisodeState.InProgress },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import ru.stenfredd.movies.core.jellyfin.Account
|
|||||||
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
||||||
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
|
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
|
||||||
import ru.stenfredd.movies.playback.core.PlaybackDefaults
|
import ru.stenfredd.movies.playback.core.PlaybackDefaults
|
||||||
|
import ru.stenfredd.movies.playback.core.SkipMode
|
||||||
import ru.stenfredd.movies.ui.Screen
|
import ru.stenfredd.movies.ui.Screen
|
||||||
import ru.stenfredd.movies.ui.components.Avatar
|
import ru.stenfredd.movies.ui.components.Avatar
|
||||||
import ru.stenfredd.movies.ui.components.Chip
|
import ru.stenfredd.movies.ui.components.Chip
|
||||||
@@ -296,8 +297,25 @@ private fun PlaybackSection(vm: SettingsViewModel, focus: ScreenFocus) {
|
|||||||
Choices("dv", options, defaults.dolbyVision, focus) { set(defaults.copy(dolbyVision = it)) }
|
Choices("dv", options, defaults.dolbyVision, focus) { set(defaults.copy(dolbyVision = it)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Setting(
|
||||||
|
"Заставки",
|
||||||
|
"Опенинги, пересказ прошлых серий, анонсы. «Кнопка» — в начале заставки появляется «Пропустить заставку», " +
|
||||||
|
"«Сразу» — плеер перескакивает её сам. Работает там, где сервер знает заставки (плагин Intro Skipper).",
|
||||||
|
) {
|
||||||
|
Choices("skip-intro", skipOptions, defaults.skipIntro, focus) { set(defaults.copy(skipIntro = it)) }
|
||||||
|
}
|
||||||
|
Setting(
|
||||||
|
"Титры",
|
||||||
|
"«Кнопка» — с началом титров появляется карточка «Следующая серия» с отсчётом (или «Пропустить титры», если " +
|
||||||
|
"после них ещё что-то есть). «Сразу» — следующая серия включается, как только начались титры. «Не пропускать» — " +
|
||||||
|
"карточка за 30 с до конца серии.",
|
||||||
|
) {
|
||||||
|
Choices("skip-credits", skipOptions, defaults.skipCredits, focus) { set(defaults.copy(skipCredits = it)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val skipOptions = listOf(Option(SkipMode.Button, "Кнопка"), Option(SkipMode.Auto, "Сразу"), Option(SkipMode.Off, "Не пропускать"))
|
||||||
|
|
||||||
// --- Языки ---
|
// --- Языки ---
|
||||||
|
|
||||||
private val AudioLanguages = listOf("rus" to "Русский", "eng" to "Английский", "jpn" to "Японский")
|
private val AudioLanguages = listOf("rus" to "Русский", "eng" to "Английский", "jpn" to "Японский")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ru.stenfredd.movies.core.data
|
|||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.mediaSegmentApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.personApi
|
import org.jellyfin.sdk.api.client.extensions.personApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.showApi
|
import org.jellyfin.sdk.api.client.extensions.showApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewApi
|
import org.jellyfin.sdk.api.client.extensions.userViewApi
|
||||||
@@ -18,6 +19,7 @@ import ru.stenfredd.movies.core.data.model.ItemSort
|
|||||||
import ru.stenfredd.movies.core.data.model.ItemSummary
|
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||||
import ru.stenfredd.movies.core.data.model.LibraryType
|
import ru.stenfredd.movies.core.data.model.LibraryType
|
||||||
import ru.stenfredd.movies.core.data.model.LibraryView
|
import ru.stenfredd.movies.core.data.model.LibraryView
|
||||||
|
import ru.stenfredd.movies.core.data.model.MediaSegment
|
||||||
import ru.stenfredd.movies.core.data.model.Page
|
import ru.stenfredd.movies.core.data.model.Page
|
||||||
import ru.stenfredd.movies.core.data.model.Person
|
import ru.stenfredd.movies.core.data.model.Person
|
||||||
import ru.stenfredd.movies.core.data.model.itemKinds
|
import ru.stenfredd.movies.core.data.model.itemKinds
|
||||||
@@ -101,6 +103,11 @@ class LibraryRepository @Inject constructor(
|
|||||||
session.call { libraryApi.getItem(itemId = id, userId = userId).content }.toDetails()
|
session.call { libraryApi.getItem(itemId = id, userId = userId).content }.toDetails()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Intro, credits and other skippable parts of [id] in time order; empty when the server has none. */
|
||||||
|
suspend fun segments(id: UUID): List<MediaSegment> = cache.get("segments:$id", Kind.Stable) {
|
||||||
|
session.call { mediaSegmentApi.getItemSegments(itemId = id).content.items }.mapNotNull { it.toSegment() }.sortedBy { it.startMs }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun seasons(seriesId: UUID): List<ItemSummary> = cache.get("seasons:$seriesId", Kind.UserData) {
|
suspend fun seasons(seriesId: UUID): List<ItemSummary> = cache.get("seasons:$seriesId", Kind.UserData) {
|
||||||
session.call { showApi.getSeasons(seriesId = seriesId, userId = userId, fields = CARD_FIELDS).content.items }.map { it.toSummary() }
|
session.call { showApi.getSeasons(seriesId = seriesId, userId = userId, fields = CARD_FIELDS).content.items }.map { it.toSummary() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import org.jellyfin.sdk.model.api.BaseItemPerson
|
|||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.ItemFields
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
@@ -21,8 +23,10 @@ import ru.stenfredd.movies.core.data.model.ItemSummary
|
|||||||
import ru.stenfredd.movies.core.data.model.LibraryType
|
import ru.stenfredd.movies.core.data.model.LibraryType
|
||||||
import ru.stenfredd.movies.core.data.model.LibraryView
|
import ru.stenfredd.movies.core.data.model.LibraryView
|
||||||
import ru.stenfredd.movies.core.data.model.MediaInfo
|
import ru.stenfredd.movies.core.data.model.MediaInfo
|
||||||
|
import ru.stenfredd.movies.core.data.model.MediaSegment
|
||||||
import ru.stenfredd.movies.core.data.model.Person
|
import ru.stenfredd.movies.core.data.model.Person
|
||||||
import ru.stenfredd.movies.core.data.model.PersonKind
|
import ru.stenfredd.movies.core.data.model.PersonKind
|
||||||
|
import ru.stenfredd.movies.core.data.model.SegmentKind
|
||||||
import ru.stenfredd.movies.core.data.model.SubtitleTrack
|
import ru.stenfredd.movies.core.data.model.SubtitleTrack
|
||||||
import ru.stenfredd.movies.core.data.model.UserState
|
import ru.stenfredd.movies.core.data.model.UserState
|
||||||
import ru.stenfredd.movies.core.data.model.VideoRange
|
import ru.stenfredd.movies.core.data.model.VideoRange
|
||||||
@@ -100,6 +104,19 @@ internal fun BaseItemDto.toDetails(): ItemDetails = ItemDetails(
|
|||||||
chapters = chapters.orEmpty().map { Chapter(it.name.orEmpty(), it.startPositionTicks / TICKS_PER_MS) },
|
chapters = chapters.orEmpty().map { Chapter(it.name.orEmpty(), it.startPositionTicks / TICKS_PER_MS) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** null for segments of unknown kind and empty ones. */
|
||||||
|
internal fun MediaSegmentDto.toSegment(): MediaSegment? {
|
||||||
|
val kind = when (type) {
|
||||||
|
MediaSegmentType.INTRO -> SegmentKind.Intro
|
||||||
|
MediaSegmentType.RECAP -> SegmentKind.Recap
|
||||||
|
MediaSegmentType.PREVIEW -> SegmentKind.Preview
|
||||||
|
MediaSegmentType.OUTRO -> SegmentKind.Outro
|
||||||
|
MediaSegmentType.COMMERCIAL -> SegmentKind.Commercial
|
||||||
|
MediaSegmentType.UNKNOWN -> return null
|
||||||
|
}
|
||||||
|
return MediaSegment(kind, startTicks / TICKS_PER_MS, endTicks / TICKS_PER_MS).takeIf { it.endMs > it.startMs }
|
||||||
|
}
|
||||||
|
|
||||||
internal fun BaseItemDto.toLibraryView(): LibraryView = LibraryView(
|
internal fun BaseItemDto.toLibraryView(): LibraryView = LibraryView(
|
||||||
id = id,
|
id = id,
|
||||||
name = name.orEmpty(),
|
name = name.orEmpty(),
|
||||||
|
|||||||
@@ -43,15 +43,15 @@ class WatchRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Continue watching": started items first (most recent first), then the next episode of each series in progress
|
* "Continue watching", one card per movie or series: started items first (most recent first; of a series only its
|
||||||
* that isn't already there. Of one library when [parentId] is given.
|
* most recently watched episode), then the next episode of each series in progress that has none started.
|
||||||
|
* Of one library when [parentId] is given.
|
||||||
*/
|
*/
|
||||||
suspend fun continueWatching(limit: Int = 20, parentId: UUID? = null): List<ItemSummary> = coroutineScope {
|
suspend fun continueWatching(limit: Int = 20, parentId: UUID? = null): List<ItemSummary> = coroutineScope {
|
||||||
val resume = async { resume(limit, parentId) }
|
// Several started episodes of a series fold into one card: fetch more so the list still fills up.
|
||||||
|
val resume = async { resume(limit * RESUME_PER_CARD, parentId) }
|
||||||
val nextUp = async { nextUp(limit, parentId) }
|
val nextUp = async { nextUp(limit, parentId) }
|
||||||
val started = resume.await()
|
(resume.await() + nextUp.await()).distinctBy { it.seriesId ?: it.id }.take(limit)
|
||||||
val seriesStarted = started.mapNotNullTo(HashSet()) { it.seriesId }
|
|
||||||
(started + nextUp.await().filter { it.seriesId !in seriesStarted && started.none { s -> s.id == it.id } }).take(limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Watched movies and episodes, most recent first; of one library when [parentId] is given. */
|
/** Watched movies and episodes, most recent first; of one library when [parentId] is given. */
|
||||||
@@ -82,5 +82,7 @@ class WatchRepository @Inject constructor(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val FAVORITES_LIMIT = 500
|
const val FAVORITES_LIMIT = 500
|
||||||
|
/** Started items fetched per "continue watching" card. */
|
||||||
|
const val RESUME_PER_CARD = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,21 @@ data class ItemDetails(
|
|||||||
|
|
||||||
data class Chapter(val name: String, val startMs: Long)
|
data class Chapter(val name: String, val startMs: Long)
|
||||||
|
|
||||||
|
/** A part of a movie or an episode the player can skip, from the server's Media Segments (e.g. the Intro Skipper plugin). */
|
||||||
|
data class MediaSegment(val kind: SegmentKind, val startMs: Long, val endMs: Long)
|
||||||
|
|
||||||
|
enum class SegmentKind {
|
||||||
|
/** Opening, title sequence. */
|
||||||
|
Intro,
|
||||||
|
/** "Previously on". */
|
||||||
|
Recap,
|
||||||
|
/** A look at the next episode. */
|
||||||
|
Preview,
|
||||||
|
/** Credits, ending. */
|
||||||
|
Outro,
|
||||||
|
Commercial,
|
||||||
|
}
|
||||||
|
|
||||||
/** The file of a playable item: what the format badges and the "file" line show, and the track choices. */
|
/** The file of a playable item: what the format badges and the "file" line show, and the track choices. */
|
||||||
data class MediaInfo(
|
data class MediaInfo(
|
||||||
val sourceId: String,
|
val sourceId: String,
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ class JellyfinSession @Inject constructor(
|
|||||||
private val _session = MutableStateFlow<SessionInfo?>(null)
|
private val _session = MutableStateFlow<SessionInfo?>(null)
|
||||||
val session: StateFlow<SessionInfo?> = _session.asStateFlow()
|
val session: StateFlow<SessionInfo?> = _session.asStateFlow()
|
||||||
|
|
||||||
|
private val _restored = MutableStateFlow(false)
|
||||||
|
/** True once [restore] has run: before that a null [session] means "not read yet", not "signed out". */
|
||||||
|
val restored: StateFlow<Boolean> = _restored.asStateFlow()
|
||||||
|
|
||||||
private val _accounts = MutableStateFlow<List<Account>>(emptyList())
|
private val _accounts = MutableStateFlow<List<Account>>(emptyList())
|
||||||
/** Saved accounts, most recently used first. */
|
/** Saved accounts, most recently used first. */
|
||||||
val accounts: StateFlow<List<Account>> = _accounts.asStateFlow()
|
val accounts: StateFlow<List<Account>> = _accounts.asStateFlow()
|
||||||
@@ -125,9 +129,10 @@ class JellyfinSession @Inject constructor(
|
|||||||
currentId = account.id
|
currentId = account.id
|
||||||
}
|
}
|
||||||
_accounts.value = accounts.sortedByDescending { it.lastUsedAt }
|
_accounts.value = accounts.sortedByDescending { it.lastUsedAt }
|
||||||
val current = accounts.firstOrNull { it.id == currentId } ?: return false
|
val current = accounts.firstOrNull { it.id == currentId }
|
||||||
activate(current)
|
current?.let(::activate)
|
||||||
true
|
_restored.value = true
|
||||||
|
current != null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Saves the account of a successful login (see [ServerLogin]) and makes it current. */
|
/** Saves the account of a successful login (see [ServerLogin]) and makes it current. */
|
||||||
|
|||||||
@@ -38,13 +38,26 @@ data class TrackPick(val language: String?, val name: String?, val codec: String
|
|||||||
/**
|
/**
|
||||||
* Playback defaults from the settings screen, for everyone on this TV (they depend on the TV and the network).
|
* Playback defaults from the settings screen, for everyone on this TV (they depend on the TV and the network).
|
||||||
* [maxBitrate] null = the original file; [dolbyVision] = decode Dolby Vision files with the DV decoder.
|
* [maxBitrate] null = the original file; [dolbyVision] = decode Dolby Vision files with the DV decoder.
|
||||||
|
* [skipIntro]: openings, recaps, previews, ads; [skipCredits]: credits (then the next episode).
|
||||||
*/
|
*/
|
||||||
data class PlaybackDefaults(
|
data class PlaybackDefaults(
|
||||||
val maxBitrate: Int? = null,
|
val maxBitrate: Int? = null,
|
||||||
val videoPath: VideoPathSwitchable.Mode = VideoPathSwitchable.Mode.AUTO,
|
val videoPath: VideoPathSwitchable.Mode = VideoPathSwitchable.Mode.AUTO,
|
||||||
val dolbyVision: Boolean = false,
|
val dolbyVision: Boolean = false,
|
||||||
|
val skipIntro: SkipMode = SkipMode.Button,
|
||||||
|
val skipCredits: SkipMode = SkipMode.Button,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** What the player does with a part of the video the server marked as skippable. */
|
||||||
|
enum class SkipMode {
|
||||||
|
/** Offers "Пропустить …". */
|
||||||
|
Button,
|
||||||
|
/** Skips it on its own. */
|
||||||
|
Auto,
|
||||||
|
/** Plays it as is. */
|
||||||
|
Off,
|
||||||
|
}
|
||||||
|
|
||||||
/** What the user picked last for a movie or a series. [subtitle] null with [subtitleOff] = subtitles off. */
|
/** What the user picked last for a movie or a series. [subtitle] null with [subtitleOff] = subtitles off. */
|
||||||
data class RememberedTracks(val audio: TrackPick?, val subtitle: TrackPick?, val subtitleOff: Boolean)
|
data class RememberedTracks(val audio: TrackPick?, val subtitle: TrackPick?, val subtitleOff: Boolean)
|
||||||
|
|
||||||
@@ -70,6 +83,8 @@ class PlaybackPreferences @Inject constructor(
|
|||||||
maxBitrate = prefs[KEY_MAX_BITRATE]?.takeIf { it > 0 },
|
maxBitrate = prefs[KEY_MAX_BITRATE]?.takeIf { it > 0 },
|
||||||
videoPath = prefs[KEY_VIDEO_PATH]?.let { runCatching { VideoPathSwitchable.Mode.valueOf(it) }.getOrNull() } ?: VideoPathSwitchable.Mode.AUTO,
|
videoPath = prefs[KEY_VIDEO_PATH]?.let { runCatching { VideoPathSwitchable.Mode.valueOf(it) }.getOrNull() } ?: VideoPathSwitchable.Mode.AUTO,
|
||||||
dolbyVision = prefs[KEY_DOLBY_VISION] ?: false,
|
dolbyVision = prefs[KEY_DOLBY_VISION] ?: false,
|
||||||
|
skipIntro = prefs[KEY_SKIP_INTRO].toSkipMode(),
|
||||||
|
skipCredits = prefs[KEY_SKIP_CREDITS].toSkipMode(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,9 +95,13 @@ class PlaybackPreferences @Inject constructor(
|
|||||||
it[KEY_MAX_BITRATE] = defaults.maxBitrate ?: 0
|
it[KEY_MAX_BITRATE] = defaults.maxBitrate ?: 0
|
||||||
it[KEY_VIDEO_PATH] = defaults.videoPath.name
|
it[KEY_VIDEO_PATH] = defaults.videoPath.name
|
||||||
it[KEY_DOLBY_VISION] = defaults.dolbyVision
|
it[KEY_DOLBY_VISION] = defaults.dolbyVision
|
||||||
|
it[KEY_SKIP_INTRO] = defaults.skipIntro.name
|
||||||
|
it[KEY_SKIP_CREDITS] = defaults.skipCredits.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun String?.toSkipMode(): SkipMode = this?.let { runCatching { SkipMode.valueOf(it) }.getOrNull() } ?: SkipMode.Button
|
||||||
|
|
||||||
/** [key]: the series of an episode, else the item itself. */
|
/** [key]: the series of an episode, else the item itself. */
|
||||||
suspend fun tracks(key: UUID): RememberedTracks? {
|
suspend fun tracks(key: UUID): RememberedTracks? {
|
||||||
val raw = context.playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
|
val raw = context.playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
|
||||||
@@ -138,5 +157,7 @@ class PlaybackPreferences @Inject constructor(
|
|||||||
val KEY_MAX_BITRATE = intPreferencesKey("max_bitrate")
|
val KEY_MAX_BITRATE = intPreferencesKey("max_bitrate")
|
||||||
val KEY_VIDEO_PATH = stringPreferencesKey("video_path")
|
val KEY_VIDEO_PATH = stringPreferencesKey("video_path")
|
||||||
val KEY_DOLBY_VISION = booleanPreferencesKey("dolby_vision")
|
val KEY_DOLBY_VISION = booleanPreferencesKey("dolby_vision")
|
||||||
|
val KEY_SKIP_INTRO = stringPreferencesKey("skip_intro")
|
||||||
|
val KEY_SKIP_CREDITS = stringPreferencesKey("skip_credits")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,19 @@ class PlaybackSession(
|
|||||||
reportProgressAsync()
|
reportProgressAsync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app left the screen (Home on the remote) with the player open: pause, save the position on the server and
|
||||||
|
* say the item's progress changed, so lists outside the app (the TV's home screen) show it.
|
||||||
|
*/
|
||||||
|
fun pauseInBackground() {
|
||||||
|
val e = engine ?: return
|
||||||
|
if (e.state.value.playWhenReady) e.pause()
|
||||||
|
scope.launch {
|
||||||
|
reportProgress()
|
||||||
|
session.notifyUserDataChanged(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun seekBy(deltaMs: Long) = seekTo(positionMs() + deltaMs)
|
fun seekBy(deltaMs: Long) = seekTo(positionMs() + deltaMs)
|
||||||
|
|
||||||
fun seekTo(positionMs: Long) {
|
fun seekTo(positionMs: Long) {
|
||||||
|
|||||||
Reference in New Issue
Block a user