mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into develop/ac3-test
This commit is contained in:
commit
2d193f4aee
134 changed files with 6151 additions and 4180 deletions
1
.github/ISSUE_TEMPLATE/01-playback.yml
vendored
1
.github/ISSUE_TEMPLATE/01-playback.yml
vendored
|
|
@ -29,6 +29,7 @@ body:
|
||||||
options:
|
options:
|
||||||
- ExoPlayer (default)
|
- ExoPlayer (default)
|
||||||
- MPV
|
- MPV
|
||||||
|
- External App (please specify)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
|
|
|
||||||
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
|
|
@ -16,7 +16,7 @@ concurrency:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
BRANCH_NAME: ${{ github.ref_name }}
|
BRANCH_NAME: ${{ github.ref_name }}
|
||||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'"
|
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
|
||||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
|
|
@ -9,7 +9,7 @@ defaults:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
BUILD_DIRS_ARTIFACT: build-dirs
|
BUILD_DIRS_ARTIFACT: build-dirs
|
||||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'"
|
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit:
|
pre-commit:
|
||||||
|
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -11,7 +11,7 @@ defaults:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
APK_SHORT_NAME: Wholphin.apk
|
APK_SHORT_NAME: Wholphin.apk
|
||||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'"
|
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
|
||||||
TAG_NAME: ${{ github.ref_name }}
|
TAG_NAME: ${{ github.ref_name }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ Also, it's recommend to add an extra ruleset jar for Compose-specific KtLint: ht
|
||||||
|
|
||||||
Also setup [pre-commit](https://github.com/pre-commit/pre-commit) which will run `ktlint` as well on each commit, plus check for other common issues.
|
Also setup [pre-commit](https://github.com/pre-commit/pre-commit) which will run `ktlint` as well on each commit, plus check for other common issues.
|
||||||
|
|
||||||
|
#### Extensions
|
||||||
|
|
||||||
|
Wholphin uses several native components for extra playback compatibility. This includes Media3 ffmpeg/av1 decoders and `libmpv`. These extensions are not required to build the app, but without them some functionality will not work.
|
||||||
|
|
||||||
|
If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository.
|
||||||
|
|
||||||
|
You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry.
|
||||||
|
|
||||||
## Code organization
|
## Code organization
|
||||||
|
|
||||||
Code is split into several packages:
|
Code is split into several packages:
|
||||||
|
|
@ -43,21 +51,22 @@ Code is split into several packages:
|
||||||
- `ui` - User interface code and ViewModels
|
- `ui` - User interface code and ViewModels
|
||||||
- `util` - Utility classes and functions
|
- `util` - Utility classes and functions
|
||||||
|
|
||||||
## Extensions
|
### Settings
|
||||||
|
|
||||||
Wholphin uses several native components for extra playback compatibility. This includes Media3 ffmpeg/av1 decoders and `libmpv`. These extensions are not required to build the app, but without them some functionality will not work.
|
There are a few different ways user settings are stored:
|
||||||
|
1. `AppPreferences` via DataStore
|
||||||
|
2. Room database
|
||||||
|
3. Key-Value DataStore
|
||||||
|
|
||||||
If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository.
|
#### App preferences
|
||||||
|
|
||||||
You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry.
|
These are generally settings that apply across the whole app regardless of the currently active user.
|
||||||
|
|
||||||
### App settings
|
The `AppPreferences` object can be retrieved from the `UserPreferencesService` or directly via injecting `DataStore<AppPreferences>`.
|
||||||
|
|
||||||
App settings are available with the `AppPreferences` object and defined by different `AppPreference` objects (note the `s` differences).
|
The `AppPreference` (note the `s` differences) objects are used to create the UI for configuring settings using the composable functions in `com.github.damontecres.wholphin.ui.preferences`.
|
||||||
|
|
||||||
The `AppPreference` objects are used to create the UI for configuring settings using the composable functions in `com.github.damontecres.wholphin.ui.preferences`.
|
##### How to add a new app setting
|
||||||
|
|
||||||
#### How to add a new app setting
|
|
||||||
|
|
||||||
1. Add entry in `WholphinDataStore.proto` & build to generate classes
|
1. Add entry in `WholphinDataStore.proto` & build to generate classes
|
||||||
2. Add new `AppPreference` object in `AppPreference.kt`
|
2. Add new `AppPreference` object in `AppPreference.kt`
|
||||||
|
|
@ -65,3 +74,24 @@ The `AppPreference` objects are used to create the UI for configuring settings u
|
||||||
4. Update `AppPreferencesSerializer` to set the default value for new installs
|
4. Update `AppPreferencesSerializer` to set the default value for new installs
|
||||||
5. If needed, update `AppUpgradeHandler` to set the default value for app upgrades
|
5. If needed, update `AppUpgradeHandler` to set the default value for app upgrades
|
||||||
- Since preferences use proto3, the [default values](https://protobuf.dev/programming-guides/proto3/#default) are zero, false, or the first enum, so only need this step if the default value is different
|
- Since preferences use proto3, the [default values](https://protobuf.dev/programming-guides/proto3/#default) are zero, false, or the first enum, so only need this step if the default value is different
|
||||||
|
|
||||||
|
#### Room settings
|
||||||
|
|
||||||
|
These are settings were generally are applied per user. They are stored in several different tables. The entities are defined in `com.github.damontecres.wholphin.data.model`.
|
||||||
|
|
||||||
|
Additionally, all server and user info for both Jellyfin and Seerr are stored in Room tables.
|
||||||
|
|
||||||
|
Some examples are:
|
||||||
|
- `JellyfinServer`
|
||||||
|
- `JellyfinUser`
|
||||||
|
- `SeerrServer`
|
||||||
|
- `LibraryDisplayInfo`
|
||||||
|
- `ItemPlayback`
|
||||||
|
|
||||||
|
Some of the models, such as `GetItemsFilter` or `ViewOptions`, are stored as JSON in a column instead of defined entities. This should be used sparingly.
|
||||||
|
|
||||||
|
#### Key-value settings
|
||||||
|
|
||||||
|
These settings are similar to Room ones and are usually stored per user. These can be accessed via `KeyValueService`. The values are `@Serializable` objects saved as JSON.
|
||||||
|
|
||||||
|
This should be used sparingly.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
import com.android.build.api.dsl.ProductFlavor
|
import com.android.build.api.dsl.ProductFlavor
|
||||||
|
import com.android.build.api.variant.FilterConfiguration
|
||||||
import com.google.protobuf.gradle.id
|
import com.google.protobuf.gradle.id
|
||||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
|
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
|
||||||
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
|
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
|
||||||
|
|
@ -37,7 +39,15 @@ val gitDescribe =
|
||||||
.standardOutput.asText
|
.standardOutput.asText
|
||||||
.getOrElse("v0.0.0")
|
.getOrElse("v0.0.0")
|
||||||
|
|
||||||
android {
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
||||||
|
jvmTarget = JvmTarget.JVM_11
|
||||||
|
javaParameters = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configure<ApplicationExtension> {
|
||||||
namespace = "com.github.damontecres.wholphin"
|
namespace = "com.github.damontecres.wholphin"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
|
|
||||||
|
|
@ -98,18 +108,6 @@ android {
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
}
|
}
|
||||||
|
|
||||||
applicationVariants.all {
|
|
||||||
val variant = this
|
|
||||||
variant.outputs
|
|
||||||
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
|
|
||||||
.forEach { output ->
|
|
||||||
val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" }
|
|
||||||
val outputFileName =
|
|
||||||
"Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk"
|
|
||||||
output.outputFileName = outputFileName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
flavorDimensions += "version"
|
flavorDimensions += "version"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
|
|
@ -148,20 +146,11 @@ android {
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
kotlin {
|
|
||||||
compilerOptions {
|
|
||||||
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
|
||||||
jvmTarget = JvmTarget.JVM_11
|
|
||||||
javaParameters = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
room {
|
|
||||||
schemaDirectory("$projectDir/schemas")
|
|
||||||
}
|
|
||||||
|
|
||||||
splits {
|
splits {
|
||||||
abi {
|
abi {
|
||||||
|
|
@ -195,6 +184,26 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants(selector().all()) { variant ->
|
||||||
|
variant.outputs
|
||||||
|
.map { it as com.android.build.api.variant.impl.VariantOutputImpl }
|
||||||
|
.forEach { output ->
|
||||||
|
val abi =
|
||||||
|
output
|
||||||
|
.getFilter(FilterConfiguration.FilterType.ABI)
|
||||||
|
.let { if (it != null) "-${it.identifier}" else "" }
|
||||||
|
val outputFileName =
|
||||||
|
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
||||||
|
output.outputFileName = outputFileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
room {
|
||||||
|
schemaDirectory("$projectDir/schemas")
|
||||||
|
}
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
protoc {
|
protoc {
|
||||||
artifact = "com.google.protobuf:protoc:${libs.protobuf.kotlin.lite.get().version}"
|
artifact = "com.google.protobuf:protoc:${libs.protobuf.kotlin.lite.get().version}"
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@
|
||||||
android:theme="@style/Theme.Wholphin"
|
android:theme="@style/Theme.Wholphin"
|
||||||
android:name=".WholphinApplication"
|
android:name=".WholphinApplication"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:networkSecurityConfig="@xml/network_security_config">
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:largeHeap="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||||
|
|
@ -55,6 +54,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||||
|
|
@ -83,6 +83,7 @@ import javax.inject.Inject
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
private val viewModel: MainActivityViewModel by viewModels()
|
private val viewModel: MainActivityViewModel by viewModels()
|
||||||
|
private val playExternalViewModel: PlayExternalViewModel by viewModels()
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
|
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
|
||||||
|
|
@ -153,12 +154,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
if (backStackStr != null) {
|
if (backStackStr != null) {
|
||||||
Timber.d("Restoring back stack")
|
Timber.d("Restoring back stack")
|
||||||
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
|
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
|
||||||
if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) {
|
|
||||||
|
if (!playExternalViewModel.launched.value) {
|
||||||
val lastDest = backStack.lastOrNull()
|
val lastDest = backStack.lastOrNull()
|
||||||
if (lastDest is Destination.Playback ||
|
if (lastDest.isPlayback) {
|
||||||
lastDest is Destination.PlaybackList ||
|
|
||||||
lastDest is Destination.Slideshow
|
|
||||||
) {
|
|
||||||
Timber.v("Restoring back stack with playback")
|
Timber.v("Restoring back stack with playback")
|
||||||
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
|
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
|
||||||
}
|
}
|
||||||
|
|
@ -220,14 +219,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
signInAuto = appPreferences.signInAutomatically
|
signInAuto = appPreferences.signInAutomatically
|
||||||
}
|
}
|
||||||
CoilConfig(
|
CoilConfig(
|
||||||
diskCacheSizeBytes =
|
prefs = appPreferences,
|
||||||
appPreferences.advancedPreferences.imageDiskCacheSizeBytes.let {
|
|
||||||
if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) {
|
|
||||||
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
},
|
|
||||||
okHttpClient = okHttpClient,
|
okHttpClient = okHttpClient,
|
||||||
debugLogging = false,
|
debugLogging = false,
|
||||||
enableCache = true,
|
enableCache = true,
|
||||||
|
|
@ -279,6 +271,14 @@ class MainActivity : AppCompatActivity() {
|
||||||
super.onRestart()
|
super.onRestart()
|
||||||
Timber.d("onRestart")
|
Timber.d("onRestart")
|
||||||
viewModel.appStart()
|
viewModel.appStart()
|
||||||
|
if (!playExternalViewModel.launched.value) {
|
||||||
|
// If restarting during playback that is not external, go back a page
|
||||||
|
val lastDest = navigationManager.backStack.lastOrNull()
|
||||||
|
if (lastDest.isPlayback) {
|
||||||
|
Timber.v("onRestart: go back from playback")
|
||||||
|
navigationManager.goBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
|
|
@ -479,3 +479,9 @@ class MainActivityViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val Destination?.isPlayback: Boolean
|
||||||
|
get() =
|
||||||
|
this is Destination.Playback ||
|
||||||
|
this is Destination.PlaybackList ||
|
||||||
|
this is Destination.Slideshow
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ fun MainContent(
|
||||||
|
|
||||||
is SetupDestination.UserList -> {
|
is SetupDestination.UserList -> {
|
||||||
SwitchUserContent(
|
SwitchUserContent(
|
||||||
currentServer = key.server,
|
server = key.server,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
|
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||||
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
|
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
|
@ -30,6 +32,7 @@ import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -48,6 +51,10 @@ class WholphinDreamService :
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var preferencesDataStore: DataStore<AppPreferences>
|
lateinit var preferencesDataStore: DataStore<AppPreferences>
|
||||||
|
|
||||||
|
@AuthOkHttpClient
|
||||||
|
@Inject
|
||||||
|
lateinit var okHttpClient: OkHttpClient
|
||||||
|
|
||||||
private val lifecycleRegistry = LifecycleRegistry(this)
|
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||||
|
|
||||||
private val savedStateRegistryController =
|
private val savedStateRegistryController =
|
||||||
|
|
@ -88,6 +95,12 @@ class WholphinDreamService :
|
||||||
preferencesDataStore.data.collectLatest { prefs = it }
|
preferencesDataStore.data.collectLatest { prefs = it }
|
||||||
}
|
}
|
||||||
prefs?.let { prefs ->
|
prefs?.let { prefs ->
|
||||||
|
CoilConfig(
|
||||||
|
prefs = prefs,
|
||||||
|
okHttpClient = okHttpClient,
|
||||||
|
debugLogging = false,
|
||||||
|
enableCache = true,
|
||||||
|
)
|
||||||
WholphinTheme(appThemeColors = prefs.interfacePreferences.appThemeColors) {
|
WholphinTheme(appThemeColors = prefs.interfacePreferences.appThemeColors) {
|
||||||
ProvideLocalClock {
|
ProvideLocalClock {
|
||||||
val screensaverPrefs = prefs.interfacePreferences.screensaverPreference
|
val screensaverPrefs = prefs.interfacePreferences.screensaverPreference
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,11 @@ sealed interface ExtrasItem {
|
||||||
val parentId: UUID
|
val parentId: UUID
|
||||||
val type: ExtraType
|
val type: ExtraType
|
||||||
val destination: Destination
|
val destination: Destination
|
||||||
val title: String?
|
val imageUrl: String?
|
||||||
|
val title: String
|
||||||
|
val subtitle: String?
|
||||||
|
val isPlayed: Boolean
|
||||||
|
val playedPercentage: Double
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents multiple extras of the same type
|
* Represents multiple extras of the same type
|
||||||
|
|
@ -24,11 +28,16 @@ sealed interface ExtrasItem {
|
||||||
override val parentId: UUID,
|
override val parentId: UUID,
|
||||||
override val type: ExtraType,
|
override val type: ExtraType,
|
||||||
val items: List<BaseItem>,
|
val items: List<BaseItem>,
|
||||||
|
override val imageUrl: String?,
|
||||||
|
override val title: String,
|
||||||
|
override val subtitle: String,
|
||||||
|
override val isPlayed: Boolean,
|
||||||
) : ExtrasItem {
|
) : ExtrasItem {
|
||||||
override val destination: Destination =
|
override val destination: Destination =
|
||||||
Destination.ItemGrid(null, type.stringRes, items.map { it.id })
|
Destination.ItemGrid(null, type.stringRes, items.map { it.id })
|
||||||
|
|
||||||
override val title: String? = null
|
override val playedPercentage
|
||||||
|
get() = -1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -38,12 +47,18 @@ sealed interface ExtrasItem {
|
||||||
override val parentId: UUID,
|
override val parentId: UUID,
|
||||||
override val type: ExtraType,
|
override val type: ExtraType,
|
||||||
val item: BaseItem,
|
val item: BaseItem,
|
||||||
|
override val imageUrl: String?,
|
||||||
|
override val title: String,
|
||||||
|
override val subtitle: String?,
|
||||||
) : ExtrasItem {
|
) : ExtrasItem {
|
||||||
override val destination: Destination =
|
override val destination: Destination =
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
item = item,
|
item = item,
|
||||||
)
|
)
|
||||||
override val title: String? get() = item.title
|
override val isPlayed: Boolean
|
||||||
|
get() = item.played
|
||||||
|
override val playedPercentage
|
||||||
|
get() = item.data.userData?.playedPercentage ?: 0.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ class ServerRepository
|
||||||
suspend fun changeUser(
|
suspend fun changeUser(
|
||||||
server: JellyfinServer,
|
server: JellyfinServer,
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
): CurrentUser? =
|
): CurrentUser =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
if (server.id != user.serverId) {
|
if (server.id != user.serverId) {
|
||||||
throw IllegalStateException("User is not part of the server")
|
throw IllegalStateException("User is not part of the server")
|
||||||
|
|
@ -104,15 +104,16 @@ class ServerRepository
|
||||||
currentUserId = updatedUser.id.toServerString()
|
currentUserId = updatedUser.id.toServerString()
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
|
val currentUser = CurrentUser(updatedServer, updatedUser)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
_current.value = CurrentUser(updatedServer, updatedUser)
|
_current.value = currentUser
|
||||||
_currentUserDto.value = userDto
|
_currentUserDto.value = userDto
|
||||||
}
|
}
|
||||||
getServerSharedPreferences(context).edit(true) {
|
getServerSharedPreferences(context).edit(true) {
|
||||||
putString(SERVER_URL_KEY, updatedServer.url)
|
putString(SERVER_URL_KEY, updatedServer.url)
|
||||||
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
|
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
|
||||||
}
|
}
|
||||||
return@withContext _current.value
|
return@withContext currentUser
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import java.util.UUID
|
||||||
* This is not the same thing as a Jellyfin server playlist
|
* This is not the same thing as a Jellyfin server playlist
|
||||||
*/
|
*/
|
||||||
class Playlist(
|
class Playlist(
|
||||||
items: List<BaseItem>,
|
items: List<PlaylistItem>,
|
||||||
startIndex: Int = 0,
|
startIndex: Int = 0,
|
||||||
) {
|
) {
|
||||||
val items = items.subList(startIndex, items.size)
|
val items = items.subList(startIndex, items.size)
|
||||||
|
|
@ -23,15 +23,15 @@ class Playlist(
|
||||||
|
|
||||||
fun hasNext(): Boolean = (index + 1) < items.size
|
fun hasNext(): Boolean = (index + 1) < items.size
|
||||||
|
|
||||||
fun getPreviousAndReverse(): BaseItem = items[--index]
|
fun getPreviousAndReverse(): PlaylistItem = items[--index]
|
||||||
|
|
||||||
fun getAndAdvance(): BaseItem = items[++index]
|
fun getAndAdvance(): PlaylistItem = items[++index]
|
||||||
|
|
||||||
fun peek(): BaseItem? = items.getOrNull(index + 1)
|
fun peek(): PlaylistItem? = items.getOrNull(index + 1)
|
||||||
|
|
||||||
fun upcomingItems(): List<BaseItem> = items.subList(index + 1, items.size)
|
fun upcomingItems(): List<PlaylistItem> = items.subList(index + 1, items.size)
|
||||||
|
|
||||||
fun advanceTo(id: UUID): BaseItem? {
|
fun advanceTo(id: UUID): PlaylistItem? {
|
||||||
while (hasNext()) {
|
while (hasNext()) {
|
||||||
val potential = getAndAdvance()
|
val potential = getAndAdvance()
|
||||||
if (potential.id == id) {
|
if (potential.id == id) {
|
||||||
|
|
@ -52,3 +52,22 @@ data class PlaylistInfo(
|
||||||
val count: Int,
|
val count: Int,
|
||||||
val mediaType: MediaType,
|
val mediaType: MediaType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sealed interface PlaylistItem {
|
||||||
|
val id: UUID
|
||||||
|
val item: BaseItem
|
||||||
|
|
||||||
|
data class Media(
|
||||||
|
override val item: BaseItem,
|
||||||
|
) : PlaylistItem {
|
||||||
|
override val id: UUID
|
||||||
|
get() = item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Intro(
|
||||||
|
override val item: BaseItem,
|
||||||
|
) : PlaylistItem {
|
||||||
|
override val id: UUID
|
||||||
|
get() = item.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,30 +117,6 @@ sealed interface AppPreference<Pref, T> {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// val GridJumpButtons =
|
|
||||||
// AppSwitchPreference<AppPreferences>(
|
|
||||||
// title = R.string.show_grid_jump_buttons,
|
|
||||||
// defaultValue = true,
|
|
||||||
// getter = { it.interfacePreferences.showGridJumpButtons },
|
|
||||||
// setter = { prefs, value ->
|
|
||||||
// prefs.updateInterfacePreferences { showGridJumpButtons = value }
|
|
||||||
// },
|
|
||||||
// summaryOn = R.string.enabled,
|
|
||||||
// summaryOff = R.string.disabled,
|
|
||||||
// )
|
|
||||||
|
|
||||||
// val ShowGridFooter =
|
|
||||||
// AppSwitchPreference<AppPreferences>(
|
|
||||||
// title = R.string.grid_position_footer,
|
|
||||||
// defaultValue = true,
|
|
||||||
// getter = { it.interfacePreferences.showPositionFooter },
|
|
||||||
// setter = { prefs, value ->
|
|
||||||
// prefs.updateInterfacePreferences { showPositionFooter = value }
|
|
||||||
// },
|
|
||||||
// summaryOn = R.string.show,
|
|
||||||
// summaryOff = R.string.hide,
|
|
||||||
// )
|
|
||||||
|
|
||||||
val ControllerTimeout =
|
val ControllerTimeout =
|
||||||
AppSliderPreference<AppPreferences>(
|
AppSliderPreference<AppPreferences>(
|
||||||
title = R.string.hide_controller_timeout,
|
title = R.string.hide_controller_timeout,
|
||||||
|
|
@ -513,6 +489,18 @@ sealed interface AppPreference<Pref, T> {
|
||||||
summaryOff = R.string.disabled,
|
summaryOff = R.string.disabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val CinemaMode =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.cinema_mode,
|
||||||
|
defaultValue = true,
|
||||||
|
getter = { it.playbackPreferences.cinemaMode },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updatePlaybackPreferences { cinemaMode = value }
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
|
|
||||||
val RememberSelectedTab =
|
val RememberSelectedTab =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppSwitchPreference<AppPreferences>(
|
||||||
title = R.string.remember_selected_tab,
|
title = R.string.remember_selected_tab,
|
||||||
|
|
@ -664,6 +652,13 @@ sealed interface AppPreference<Pref, T> {
|
||||||
valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 },
|
valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val SkipSegments =
|
||||||
|
AppDestinationPreference<AppPreferences>(
|
||||||
|
title = R.string.skip_behavior,
|
||||||
|
summary = R.string.skip_behavior_summary,
|
||||||
|
destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS),
|
||||||
|
)
|
||||||
|
|
||||||
val GlobalContentScale =
|
val GlobalContentScale =
|
||||||
AppChoicePreference<AppPreferences, PrefContentScale>(
|
AppChoicePreference<AppPreferences, PrefContentScale>(
|
||||||
title = R.string.global_content_scale,
|
title = R.string.global_content_scale,
|
||||||
|
|
@ -1177,6 +1172,21 @@ val MpvPreferences =
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val SkipSegmentPreferences =
|
||||||
|
listOf(
|
||||||
|
PreferenceGroup(
|
||||||
|
title = R.string.skip,
|
||||||
|
preferences =
|
||||||
|
listOf(
|
||||||
|
AppPreference.SkipIntros,
|
||||||
|
AppPreference.SkipOutros,
|
||||||
|
AppPreference.SkipCommercials,
|
||||||
|
AppPreference.SkipPreviews,
|
||||||
|
AppPreference.SkipRecaps,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
val advancedPreferences =
|
val advancedPreferences =
|
||||||
buildList {
|
buildList {
|
||||||
add(
|
add(
|
||||||
|
|
@ -1203,7 +1213,9 @@ val advancedPreferences =
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.OneClickPause,
|
AppPreference.OneClickPause,
|
||||||
|
AppPreference.CinemaMode,
|
||||||
AppPreference.GlobalContentScale,
|
AppPreference.GlobalContentScale,
|
||||||
|
AppPreference.SkipSegments,
|
||||||
AppPreference.MaxBitrate,
|
AppPreference.MaxBitrate,
|
||||||
AppPreference.RefreshRateSwitching,
|
AppPreference.RefreshRateSwitching,
|
||||||
AppPreference.ResolutionSwitching,
|
AppPreference.ResolutionSwitching,
|
||||||
|
|
@ -1211,19 +1223,6 @@ val advancedPreferences =
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
add(
|
|
||||||
PreferenceGroup(
|
|
||||||
title = R.string.skip,
|
|
||||||
preferences =
|
|
||||||
listOf(
|
|
||||||
AppPreference.SkipIntros,
|
|
||||||
AppPreference.SkipOutros,
|
|
||||||
AppPreference.SkipCommercials,
|
|
||||||
AppPreference.SkipPreviews,
|
|
||||||
AppPreference.SkipRecaps,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
add(
|
add(
|
||||||
PreferenceGroup(
|
PreferenceGroup(
|
||||||
title = R.string.player_backend,
|
title = R.string.player_backend,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ class AppPreferencesSerializer
|
||||||
refreshRateSwitching =
|
refreshRateSwitching =
|
||||||
AppPreference.RefreshRateSwitching.defaultValue
|
AppPreference.RefreshRateSwitching.defaultValue
|
||||||
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
|
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
|
||||||
|
cinemaMode = AppPreference.CinemaMode.defaultValue
|
||||||
|
|
||||||
overrides =
|
overrides =
|
||||||
PlaybackOverrides
|
PlaybackOverrides
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
||||||
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
|
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
|
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||||
|
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
|
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||||
|
|
@ -327,5 +328,11 @@ class AppUpgradeHandler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("0.6.2-18-g0"))) {
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.updatePlaybackPreferences { cinemaMode = AppPreference.CinemaMode.defaultValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.pluralRes
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.ExtraType
|
import org.jellyfin.sdk.model.api.ExtraType
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
@ -19,6 +24,8 @@ class ExtrasService
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Get the [ExtrasItem]s for the given item
|
* Get the [ExtrasItem]s for the given item
|
||||||
|
|
@ -39,9 +46,45 @@ class ExtrasService
|
||||||
extrasMap
|
extrasMap
|
||||||
.mapNotNull { (type, items) ->
|
.mapNotNull { (type, items) ->
|
||||||
if (items.size == 1) {
|
if (items.size == 1) {
|
||||||
ExtrasItem.Single(itemId, type, items.first())
|
val item = items.first()
|
||||||
|
val title =
|
||||||
|
item.title ?: context.resources.getQuantityString(type.pluralRes, 1)
|
||||||
|
val subtitle =
|
||||||
|
if (item.title == null) {
|
||||||
|
context.resources.getQuantityString(type.pluralRes, 1)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
ExtrasItem.Single(
|
||||||
|
parentId = itemId,
|
||||||
|
type = type,
|
||||||
|
item = item,
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle,
|
||||||
|
imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY),
|
||||||
|
)
|
||||||
} else if (items.size > 1) {
|
} else if (items.size > 1) {
|
||||||
ExtrasItem.Group(itemId, type, items)
|
val title =
|
||||||
|
context.resources.getQuantityString(type.pluralRes, items.size)
|
||||||
|
val subtitle =
|
||||||
|
context.resources.getQuantityString(
|
||||||
|
R.plurals.items,
|
||||||
|
items.size,
|
||||||
|
items.size,
|
||||||
|
)
|
||||||
|
ExtrasItem.Group(
|
||||||
|
parentId = itemId,
|
||||||
|
type = type,
|
||||||
|
items = items,
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle,
|
||||||
|
isPlayed = items.all { it.played },
|
||||||
|
imageUrl =
|
||||||
|
imageUrlService.getItemImageUrl(
|
||||||
|
items.random(),
|
||||||
|
ImageType.PRIMARY,
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,15 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.ui.combinePair
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
|
@ -68,6 +71,16 @@ class MediaManagementService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun collectCanDelete(
|
||||||
|
itemFlow: Flow<BaseItem?>,
|
||||||
|
update: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
itemFlow.combinePair(userPreferencesService.flow).collectLatest { (item, prefs) ->
|
||||||
|
val canDelete = item?.let { canDelete(item, prefs.appPreferences) } ?: false
|
||||||
|
update.invoke(canDelete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the item.
|
* Delete the item.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,6 @@ class PlaybackLifecycleObserver
|
||||||
private var wasPlaying: Boolean? = null
|
private var wasPlaying: Boolean? = null
|
||||||
|
|
||||||
override fun onStart(owner: LifecycleOwner) {
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
// TODO
|
|
||||||
// val lastDest = navigationManager.backStack.lastOrNull()
|
|
||||||
// if (lastDest is Destination.Playback ||
|
|
||||||
// lastDest is Destination.PlaybackList ||
|
|
||||||
// lastDest is Destination.Slideshow
|
|
||||||
// ) {
|
|
||||||
// navigationManager.goBack()
|
|
||||||
// }
|
|
||||||
wasPlaying = null
|
wasPlaying = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,13 @@ class PlayerFactory
|
||||||
.setExtensionRendererMode(rendererMode)
|
.setExtensionRendererMode(rendererMode)
|
||||||
val mediaSourceFactory =
|
val mediaSourceFactory =
|
||||||
if (useLibAss) {
|
if (useLibAss) {
|
||||||
assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL)
|
val renderType =
|
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||||
|
AssRenderType.OVERLAY_CANVAS
|
||||||
|
} else {
|
||||||
|
AssRenderType.OVERLAY_OPEN_GL
|
||||||
|
}
|
||||||
|
assHandler = AssHandler(renderType)
|
||||||
val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler)
|
val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler)
|
||||||
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
|
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
|
||||||
DefaultMediaSourceFactory(
|
DefaultMediaSourceFactory(
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
import com.github.damontecres.wholphin.data.model.Playlist
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
import com.github.damontecres.wholphin.data.model.PlaylistInfo
|
import com.github.damontecres.wholphin.data.model.PlaylistInfo
|
||||||
|
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
||||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
import com.github.damontecres.wholphin.ui.components.baseItemKinds
|
import com.github.damontecres.wholphin.ui.components.baseItemKinds
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.gt
|
import com.github.damontecres.wholphin.ui.gt
|
||||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||||
import com.github.damontecres.wholphin.ui.playback.playable
|
import com.github.damontecres.wholphin.ui.playback.playable
|
||||||
|
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||||
|
|
@ -22,6 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
|
@ -247,14 +250,14 @@ class PlaylistCreator
|
||||||
-> {
|
-> {
|
||||||
val list =
|
val list =
|
||||||
buildList {
|
buildList {
|
||||||
add(BaseItem(item, false))
|
add(PlaylistItem.Media(BaseItem(item, false)))
|
||||||
|
|
||||||
if (item.partCount.gt(1)) {
|
if (item.partCount.gt(1)) {
|
||||||
api.videosApi
|
api.videosApi
|
||||||
.getAdditionalPart(item.id)
|
.getAdditionalPart(item.id)
|
||||||
.content.items
|
.content.items
|
||||||
.map {
|
.map {
|
||||||
BaseItem(it, false)
|
PlaylistItem.Media(BaseItem(it, false))
|
||||||
}.let(::addAll)
|
}.let(::addAll)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -274,14 +277,14 @@ class PlaylistCreator
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<BaseItem> =
|
private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<PlaylistItem> =
|
||||||
buildList {
|
buildList {
|
||||||
this@convertAndAddParts.forEach { ep ->
|
this@convertAndAddParts.forEach { ep ->
|
||||||
add(BaseItem(ep, useSeriesForPrimary))
|
add(PlaylistItem.Media(BaseItem(ep, useSeriesForPrimary)))
|
||||||
if (ep.partCount.gt(1)) {
|
if (ep.partCount.gt(1)) {
|
||||||
val parts =
|
val parts =
|
||||||
api.videosApi.getAdditionalPart(ep.id).content.items.map { part ->
|
api.videosApi.getAdditionalPart(ep.id).content.items.map { part ->
|
||||||
BaseItem(part, useSeriesForPrimary)
|
PlaylistItem.Media(BaseItem(part, useSeriesForPrimary))
|
||||||
}
|
}
|
||||||
addAll(parts)
|
addAll(parts)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import android.content.Context
|
||||||
import coil3.imageLoader
|
import coil3.imageLoader
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||||
import com.github.damontecres.wholphin.ui.components.CurrentItem
|
import com.github.damontecres.wholphin.ui.components.ScreensaverItem
|
||||||
import com.github.damontecres.wholphin.ui.formatDate
|
import com.github.damontecres.wholphin.ui.formatDate
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
|
@ -154,30 +154,20 @@ class ScreensaverService
|
||||||
/**
|
/**
|
||||||
* Create a flow of items to show on the screensaver
|
* Create a flow of items to show on the screensaver
|
||||||
*/
|
*/
|
||||||
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
|
fun createItemFlow(scope: CoroutineScope): Flow<ScreensaverItem?> =
|
||||||
flow {
|
flow {
|
||||||
val prefs =
|
|
||||||
userPreferencesService.flow
|
|
||||||
.first()
|
|
||||||
.appPreferences
|
|
||||||
.interfacePreferences.screensaverPreference
|
|
||||||
val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 }
|
|
||||||
val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) }
|
|
||||||
val request =
|
|
||||||
GetItemsRequest(
|
|
||||||
recursive = true,
|
|
||||||
includeItemTypes = itemTypes,
|
|
||||||
imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP),
|
|
||||||
sortBy = listOf(ItemSortBy.RANDOM),
|
|
||||||
maxOfficialRating = maxAge?.toString(),
|
|
||||||
hasParentalRating = maxAge?.let { true },
|
|
||||||
)
|
|
||||||
val pager =
|
val pager =
|
||||||
ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
|
try {
|
||||||
|
createPager()
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error creating pager for screensaver")
|
||||||
|
emit(ScreensaverItem.Error(ex))
|
||||||
|
return@flow
|
||||||
|
}
|
||||||
Timber.v("Got %s items", pager.size)
|
Timber.v("Got %s items", pager.size)
|
||||||
var index = 0
|
var index = 0
|
||||||
if (pager.isEmpty()) {
|
if (pager.isEmpty()) {
|
||||||
emit(null)
|
emit(ScreensaverItem.Empty)
|
||||||
} else {
|
} else {
|
||||||
val duration =
|
val duration =
|
||||||
userPreferencesService
|
userPreferencesService
|
||||||
|
|
@ -213,7 +203,14 @@ class ScreensaverService
|
||||||
.build(),
|
.build(),
|
||||||
).job
|
).job
|
||||||
.await()
|
.await()
|
||||||
emit(CurrentItem(item, backdropUrl, logoUrl, title ?: ""))
|
emit(
|
||||||
|
ScreensaverItem.CurrentItem(
|
||||||
|
item,
|
||||||
|
backdropUrl,
|
||||||
|
logoUrl,
|
||||||
|
title ?: "",
|
||||||
|
),
|
||||||
|
)
|
||||||
delay(duration)
|
delay(duration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -228,6 +225,26 @@ class ScreensaverService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.flowOn(Dispatchers.Default).cancellable()
|
}.flowOn(Dispatchers.Default).cancellable()
|
||||||
|
|
||||||
|
private suspend fun createPager(): ApiRequestPager<GetItemsRequest> {
|
||||||
|
val prefs =
|
||||||
|
userPreferencesService.flow
|
||||||
|
.first()
|
||||||
|
.appPreferences
|
||||||
|
.interfacePreferences.screensaverPreference
|
||||||
|
val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 }
|
||||||
|
val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) }
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
recursive = true,
|
||||||
|
includeItemTypes = itemTypes,
|
||||||
|
imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP),
|
||||||
|
sortBy = listOf(ItemSortBy.RANDOM),
|
||||||
|
maxOfficialRating = maxAge?.toString(),
|
||||||
|
hasParentalRating = maxAge?.let { true },
|
||||||
|
)
|
||||||
|
return ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ScreensaverState(
|
data class ScreensaverState(
|
||||||
|
|
|
||||||
|
|
@ -340,7 +340,7 @@ class UpdateChecker
|
||||||
context.contentResolver.delete(
|
context.contentResolver.delete(
|
||||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
|
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
|
||||||
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
|
arrayOf("$ASSET_NAME%", APK_MIME_TYPE),
|
||||||
)
|
)
|
||||||
Timber.i("Deleted $deletedRows rows")
|
Timber.i("Deleted $deletedRows rows")
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,32 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import coil3.request.Options
|
import coil3.request.Options
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import coil3.util.DebugLogger
|
import coil3.util.DebugLogger
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.time.ExperimentalTime
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CoilConfig(
|
||||||
|
prefs: AppPreferences,
|
||||||
|
okHttpClient: OkHttpClient,
|
||||||
|
debugLogging: Boolean,
|
||||||
|
enableCache: Boolean = true,
|
||||||
|
) = CoilConfig(
|
||||||
|
diskCacheSizeBytes =
|
||||||
|
prefs.advancedPreferences.imageDiskCacheSizeBytes.let {
|
||||||
|
if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) {
|
||||||
|
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
},
|
||||||
|
okHttpClient = okHttpClient,
|
||||||
|
debugLogging = debugLogging,
|
||||||
|
enableCache = enableCache,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure Coil image loading
|
* Configure Coil image loading
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
@ -70,6 +71,8 @@ fun EpisodeCard(
|
||||||
val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN
|
val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN
|
||||||
val width = imageHeight * aspectRatio
|
val width = imageHeight * aspectRatio
|
||||||
val height = imageWidth * (1f / aspectRatio)
|
val height = imageWidth * (1f / aspectRatio)
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } }
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||||
modifier = modifier.size(width, height),
|
modifier = modifier.size(width, height),
|
||||||
|
|
@ -102,6 +105,7 @@ fun EpisodeCard(
|
||||||
watchedPercent = dto?.userData?.playedPercentage,
|
watchedPercent = dto?.userData?.playedPercentage,
|
||||||
numberOfVersions = dto?.mediaSourceCount ?: 0,
|
numberOfVersions = dto?.mediaSourceCount ?: 0,
|
||||||
useFallbackText = false,
|
useFallbackText = false,
|
||||||
|
fillWidth = imageWidthPx,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,13 @@
|
||||||
package com.github.damontecres.wholphin.ui.cards
|
package com.github.damontecres.wholphin.ui.cards
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalResources
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||||
import com.github.damontecres.wholphin.data.pluralRes
|
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ExtrasRow(
|
fun ExtrasRow(
|
||||||
|
|
@ -22,52 +16,27 @@ fun ExtrasRow(
|
||||||
onLongClickItem: (Int, ExtrasItem) -> Unit,
|
onLongClickItem: (Int, ExtrasItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
val resources = LocalResources.current
|
|
||||||
ItemRow(
|
ItemRow(
|
||||||
title = stringResource(R.string.extras),
|
title = stringResource(R.string.extras),
|
||||||
items = extras,
|
items = extras,
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
onLongClickItem = onLongClickItem,
|
onLongClickItem = onLongClickItem,
|
||||||
cardContent = { index, item, mod, onClick, onLongClick ->
|
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||||
val imageUrlService = LocalImageUrlService.current
|
|
||||||
val imageUrl =
|
|
||||||
remember {
|
|
||||||
val item =
|
|
||||||
when (item) {
|
|
||||||
is ExtrasItem.Group -> item.items.random()
|
|
||||||
is ExtrasItem.Single -> item.item
|
|
||||||
null -> null
|
|
||||||
}
|
|
||||||
imageUrlService.getItemImageUrl(item, ImageType.PRIMARY)
|
|
||||||
}
|
|
||||||
SeasonCard(
|
SeasonCard(
|
||||||
title =
|
title = item?.title,
|
||||||
when (item) {
|
|
||||||
is ExtrasItem.Group -> item.type.pluralRes.let { resources.getQuantityString(it, item.items.size) }
|
|
||||||
is ExtrasItem.Single -> item.title ?: item.type.pluralRes.let { resources.getQuantityString(it, 1) }
|
|
||||||
null -> null
|
|
||||||
},
|
|
||||||
name = null,
|
name = null,
|
||||||
subtitle =
|
subtitle = item?.subtitle,
|
||||||
if (item is ExtrasItem.Single && item.title != null) {
|
|
||||||
item.type.pluralRes.let { resources.getQuantityString(it, 1) }
|
|
||||||
} else if (item is ExtrasItem.Group) {
|
|
||||||
resources.getQuantityString(R.plurals.items, item.items.size, item.items.size)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
modifier = mod,
|
modifier = mod,
|
||||||
showImageOverlay = true,
|
showImageOverlay = true,
|
||||||
imageHeight = Cards.height2x3 * .75f,
|
imageHeight = Cards.heightEpisode,
|
||||||
imageWidth = Dp.Unspecified,
|
imageWidth = Dp.Unspecified,
|
||||||
imageUrl = imageUrl,
|
imageUrl = item?.imageUrl,
|
||||||
isFavorite = false,
|
isFavorite = false,
|
||||||
isPlayed = false,
|
isPlayed = item?.isPlayed == true,
|
||||||
unplayedItemCount = -1,
|
unplayedItemCount = -1,
|
||||||
playedPercentage = -1.0,
|
playedPercentage = item?.playedPercentage ?: 0.0,
|
||||||
numberOfVersions = -1,
|
numberOfVersions = -1,
|
||||||
aspectRatio = AspectRatios.FOUR_THREE, // TODO
|
aspectRatio = AspectRatios.FOUR_THREE, // TODO
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ fun GridCard(
|
||||||
imageContentScale: ContentScale = ContentScale.Fit,
|
imageContentScale: ContentScale = ContentScale.Fit,
|
||||||
imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
|
fillWidth: Int? = null,
|
||||||
|
fillHeight: Int? = null,
|
||||||
) {
|
) {
|
||||||
val dto = item?.data
|
val dto = item?.data
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
|
@ -95,6 +97,8 @@ fun GridCard(
|
||||||
numberOfVersions = dto?.mediaSourceCount ?: 0,
|
numberOfVersions = dto?.mediaSourceCount ?: 0,
|
||||||
useFallbackText = false,
|
useFallbackText = false,
|
||||||
contentScale = imageContentScale,
|
contentScale = imageContentScale,
|
||||||
|
fillWidth = fillWidth,
|
||||||
|
fillHeight = fillHeight,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,9 @@ import androidx.compose.ui.graphics.BlendMode
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onLayoutRectChanged
|
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
|
@ -63,20 +61,19 @@ fun ItemCardImage(
|
||||||
imageType: ImageType = ImageType.PRIMARY,
|
imageType: ImageType = ImageType.PRIMARY,
|
||||||
useFallbackText: Boolean = true,
|
useFallbackText: Boolean = true,
|
||||||
contentScale: ContentScale = ContentScale.Fit,
|
contentScale: ContentScale = ContentScale.Fit,
|
||||||
|
fillWidth: Int? = null,
|
||||||
|
fillHeight: Int? = null,
|
||||||
) {
|
) {
|
||||||
val imageUrlService = LocalImageUrlService.current
|
val imageUrlService = LocalImageUrlService.current
|
||||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
|
||||||
val imageUrl =
|
val imageUrl =
|
||||||
remember(size, item) {
|
remember(item) {
|
||||||
if (size != IntSize.Zero && item != null) {
|
item?.let {
|
||||||
imageUrlService.getItemImageUrl(
|
imageUrlService.getItemImageUrl(
|
||||||
item,
|
item,
|
||||||
imageType,
|
imageType,
|
||||||
fillWidth = size.width,
|
fillWidth = fillWidth,
|
||||||
fillHeight = size.height,
|
fillHeight = fillHeight,
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ItemCardImage(
|
ItemCardImage(
|
||||||
|
|
@ -88,13 +85,7 @@ fun ItemCardImage(
|
||||||
unwatchedCount = unwatchedCount,
|
unwatchedCount = unwatchedCount,
|
||||||
watchedPercent = watchedPercent,
|
watchedPercent = watchedPercent,
|
||||||
numberOfVersions = numberOfVersions,
|
numberOfVersions = numberOfVersions,
|
||||||
modifier =
|
modifier = modifier,
|
||||||
modifier.onLayoutRectChanged(
|
|
||||||
throttleMillis = 100,
|
|
||||||
debounceMillis = 25,
|
|
||||||
) {
|
|
||||||
size = IntSize(width = it.width, height = it.height)
|
|
||||||
},
|
|
||||||
useFallbackText = useFallbackText,
|
useFallbackText = useFallbackText,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
package com.github.damontecres.wholphin.ui.components
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -32,6 +37,7 @@ import androidx.compose.ui.graphics.Shader
|
||||||
import androidx.compose.ui.graphics.ShaderBrush
|
import androidx.compose.ui.graphics.ShaderBrush
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
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 androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
|
|
@ -45,6 +51,7 @@ import coil3.compose.AsyncImage
|
||||||
import coil3.compose.useExistingImageAsPlaceholder
|
import coil3.compose.useExistingImageAsPlaceholder
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.transitionFactory
|
import coil3.request.transitionFactory
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
|
|
@ -53,6 +60,7 @@ import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
||||||
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA
|
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA
|
||||||
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION
|
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
@ -67,12 +75,20 @@ class ScreensaverViewModel
|
||||||
val currentItem = screensaverService.createItemFlow(viewModelScope)
|
val currentItem = screensaverService.createItemFlow(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class CurrentItem(
|
sealed interface ScreensaverItem {
|
||||||
val item: BaseItem,
|
data class Error(
|
||||||
val backdropUrl: String,
|
val exception: Exception,
|
||||||
val logoUrl: String?,
|
) : ScreensaverItem
|
||||||
val title: String,
|
|
||||||
)
|
data object Empty : ScreensaverItem
|
||||||
|
|
||||||
|
data class CurrentItem(
|
||||||
|
val item: BaseItem,
|
||||||
|
val backdropUrl: String,
|
||||||
|
val logoUrl: String?,
|
||||||
|
val title: String,
|
||||||
|
) : ScreensaverItem
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppScreensaver(
|
fun AppScreensaver(
|
||||||
|
|
@ -93,7 +109,7 @@ fun AppScreensaver(
|
||||||
@OptIn(ExperimentalCoilApi::class)
|
@OptIn(ExperimentalCoilApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AppScreensaverContent(
|
fun AppScreensaverContent(
|
||||||
currentItem: CurrentItem?,
|
currentItem: ScreensaverItem?,
|
||||||
showClock: Boolean,
|
showClock: Boolean,
|
||||||
duration: Duration,
|
duration: Duration,
|
||||||
animate: Boolean,
|
animate: Boolean,
|
||||||
|
|
@ -116,61 +132,83 @@ fun AppScreensaverContent(
|
||||||
repeatMode = RepeatMode.Reverse,
|
repeatMode = RepeatMode.Reverse,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
AsyncImage(
|
when (currentItem) {
|
||||||
model =
|
ScreensaverItem.Empty -> {
|
||||||
ImageRequest
|
ScreensaverPlaceholder(
|
||||||
.Builder(LocalContext.current)
|
text = stringResource(R.string.no_results),
|
||||||
.data(currentItem?.backdropUrl)
|
duration = duration,
|
||||||
.transitionFactory(CrossFadeFactory(2000.milliseconds))
|
modifier = Modifier.fillMaxSize(),
|
||||||
.useExistingImageAsPlaceholder(true)
|
|
||||||
.build(),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = scale
|
|
||||||
scaleY = scale
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
var logoError by remember(currentItem) { mutableStateOf(false) }
|
|
||||||
if (!logoError) {
|
|
||||||
AsyncImage(
|
|
||||||
model =
|
|
||||||
ImageRequest
|
|
||||||
.Builder(LocalContext.current)
|
|
||||||
.data(currentItem?.logoUrl)
|
|
||||||
.transitionFactory(CrossFadeFactory(750.milliseconds))
|
|
||||||
.build(),
|
|
||||||
contentDescription = "Logo",
|
|
||||||
onError = {
|
|
||||||
logoError = true
|
|
||||||
},
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.size(width = 240.dp, height = 120.dp)
|
|
||||||
.padding(16.dp),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.padding(16.dp)
|
|
||||||
.fillMaxWidth(.5f)
|
|
||||||
.fillMaxHeight(.3f),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = currentItem?.title ?: "",
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
style = MaterialTheme.typography.displaySmall,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.align(Alignment.BottomStart),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is ScreensaverItem.Error -> {
|
||||||
|
ScreensaverPlaceholder(
|
||||||
|
text = "Error connecting to Jellyfin server",
|
||||||
|
duration = duration,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
null,
|
||||||
|
is ScreensaverItem.CurrentItem,
|
||||||
|
-> {
|
||||||
|
AsyncImage(
|
||||||
|
model =
|
||||||
|
ImageRequest
|
||||||
|
.Builder(LocalContext.current)
|
||||||
|
.data(currentItem?.backdropUrl)
|
||||||
|
.transitionFactory(CrossFadeFactory(2000.milliseconds))
|
||||||
|
.useExistingImageAsPlaceholder(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
var logoError by remember(currentItem) { mutableStateOf(false) }
|
||||||
|
if (!logoError) {
|
||||||
|
AsyncImage(
|
||||||
|
model =
|
||||||
|
ImageRequest
|
||||||
|
.Builder(LocalContext.current)
|
||||||
|
.data(currentItem?.logoUrl)
|
||||||
|
.transitionFactory(CrossFadeFactory(750.milliseconds))
|
||||||
|
.build(),
|
||||||
|
contentDescription = "Logo",
|
||||||
|
onError = {
|
||||||
|
logoError = true
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.size(width = 240.dp, height = 120.dp)
|
||||||
|
.padding(16.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(.5f)
|
||||||
|
.fillMaxHeight(.3f),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = currentItem?.title ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.align(Alignment.BottomStart),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val largeRadialGradient =
|
val largeRadialGradient =
|
||||||
|
|
@ -212,3 +250,53 @@ fun AppScreensaverContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScreensaverPlaceholder(
|
||||||
|
text: String,
|
||||||
|
duration: Duration,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var alignment by remember { mutableStateOf(Alignment.BottomStart) }
|
||||||
|
val alignments =
|
||||||
|
remember(alignment) {
|
||||||
|
mutableListOf(
|
||||||
|
Alignment.TopStart,
|
||||||
|
Alignment.TopCenter,
|
||||||
|
Alignment.TopEnd,
|
||||||
|
Alignment.CenterStart,
|
||||||
|
Alignment.Center,
|
||||||
|
Alignment.CenterEnd,
|
||||||
|
Alignment.BottomStart,
|
||||||
|
Alignment.BottomCenter,
|
||||||
|
Alignment.BottomEnd,
|
||||||
|
).apply { remove(alignment) }
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
delay(duration)
|
||||||
|
alignment = alignments.random()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = alignment,
|
||||||
|
label = "alignment animation",
|
||||||
|
transitionSpec = {
|
||||||
|
fadeIn(animationSpec = tween(500, delayMillis = 100))
|
||||||
|
.togetherWith(fadeOut(animationSpec = tween(500, 100)))
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
) { align ->
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(align)
|
||||||
|
.padding(40.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ import com.github.damontecres.wholphin.services.MediaManagementService
|
||||||
import com.github.damontecres.wholphin.services.MediaReportService
|
import com.github.damontecres.wholphin.services.MediaReportService
|
||||||
import com.github.damontecres.wholphin.services.MusicService
|
import com.github.damontecres.wholphin.services.MusicService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.services.deleteItem
|
import com.github.damontecres.wholphin.services.deleteItem
|
||||||
|
|
@ -74,13 +75,14 @@ import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
||||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
||||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
|
|
@ -133,7 +135,7 @@ class CollectionFolderViewModel
|
||||||
private val savedStateHandle: SavedStateHandle,
|
private val savedStateHandle: SavedStateHandle,
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
|
|
@ -142,6 +144,7 @@ class CollectionFolderViewModel
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
private val mediaManagementService: MediaManagementService,
|
private val mediaManagementService: MediaManagementService,
|
||||||
private val musicService: MusicService,
|
private val musicService: MusicService,
|
||||||
|
val streamChoiceService: StreamChoiceService,
|
||||||
val mediaReportService: MediaReportService,
|
val mediaReportService: MediaReportService,
|
||||||
@Assisted itemId: String,
|
@Assisted itemId: String,
|
||||||
@Assisted initialSortAndDirection: SortAndDirection?,
|
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||||
|
|
@ -570,7 +573,44 @@ fun CollectionFolderGrid(
|
||||||
itemId.toServerString(),
|
itemId.toServerString(),
|
||||||
initialFilter,
|
initialFilter,
|
||||||
recursive,
|
recursive,
|
||||||
onClickItem,
|
GridClickActions(onClickItem = onClickItem),
|
||||||
|
sortOptions,
|
||||||
|
playEnabled,
|
||||||
|
viewModelKey = viewModelKey,
|
||||||
|
defaultViewOptions = defaultViewOptions,
|
||||||
|
modifier = modifier,
|
||||||
|
initialSortAndDirection = initialSortAndDirection,
|
||||||
|
showTitle = showTitle,
|
||||||
|
positionCallback = positionCallback,
|
||||||
|
useSeriesForPrimary = useSeriesForPrimary,
|
||||||
|
filterOptions = filterOptions,
|
||||||
|
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CollectionFolderGrid(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
itemId: UUID,
|
||||||
|
initialFilter: CollectionFolderFilter,
|
||||||
|
recursive: Boolean,
|
||||||
|
actions: GridClickActions,
|
||||||
|
sortOptions: List<ItemSortBy>,
|
||||||
|
playEnabled: Boolean,
|
||||||
|
defaultViewOptions: ViewOptions,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModelKey: String? = itemId.toServerString(),
|
||||||
|
initialSortAndDirection: SortAndDirection? = null,
|
||||||
|
showTitle: Boolean = true,
|
||||||
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
|
useSeriesForPrimary: Boolean = true,
|
||||||
|
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||||
|
focusRequesterOnEmpty: FocusRequester? = null,
|
||||||
|
) = CollectionFolderGrid(
|
||||||
|
preferences,
|
||||||
|
itemId.toServerString(),
|
||||||
|
initialFilter,
|
||||||
|
recursive,
|
||||||
|
actions,
|
||||||
sortOptions,
|
sortOptions,
|
||||||
playEnabled,
|
playEnabled,
|
||||||
viewModelKey = viewModelKey,
|
viewModelKey = viewModelKey,
|
||||||
|
|
@ -590,7 +630,7 @@ fun CollectionFolderGrid(
|
||||||
itemId: String,
|
itemId: String,
|
||||||
initialFilter: CollectionFolderFilter,
|
initialFilter: CollectionFolderFilter,
|
||||||
recursive: Boolean,
|
recursive: Boolean,
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
actions: GridClickActions,
|
||||||
sortOptions: List<ItemSortBy>,
|
sortOptions: List<ItemSortBy>,
|
||||||
playEnabled: Boolean,
|
playEnabled: Boolean,
|
||||||
defaultViewOptions: ViewOptions,
|
defaultViewOptions: ViewOptions,
|
||||||
|
|
@ -625,11 +665,106 @@ fun CollectionFolderGrid(
|
||||||
val item by viewModel.item.observeAsState()
|
val item by viewModel.item.observeAsState()
|
||||||
val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions)
|
val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions)
|
||||||
|
|
||||||
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
var showDeleteDialog by remember { mutableStateOf<PositionItem?>(null) }
|
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
|
||||||
|
val contextActions =
|
||||||
|
remember {
|
||||||
|
ContextMenuActions(
|
||||||
|
navigateTo = viewModel::navigateTo,
|
||||||
|
onClickWatch = { itemId, watched ->
|
||||||
|
viewModel.setWatched(viewModel.position, itemId, watched)
|
||||||
|
},
|
||||||
|
onClickFavorite = { itemId, favorite ->
|
||||||
|
viewModel.setFavorite(viewModel.position, itemId, favorite)
|
||||||
|
},
|
||||||
|
onClickAddPlaylist = { itemId ->
|
||||||
|
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
|
showPlaylistDialog.makePresent(itemId)
|
||||||
|
},
|
||||||
|
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||||
|
onDeleteItem = { viewModel.deleteItem(viewModel.position, it) },
|
||||||
|
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||||
|
onChooseVersion = { _, _ ->
|
||||||
|
// Not supported on this page
|
||||||
|
},
|
||||||
|
onChooseTracks = { result ->
|
||||||
|
// Not supported on this page
|
||||||
|
},
|
||||||
|
onClearChosenStreams = {
|
||||||
|
// Not supported on this page
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val gridActions =
|
||||||
|
remember(actions) {
|
||||||
|
GridClickActions(
|
||||||
|
onClickItem = actions.onClickItem,
|
||||||
|
onLongClickItem =
|
||||||
|
actions.onLongClickItem ?: { position, item ->
|
||||||
|
showContextMenu =
|
||||||
|
ContextMenu.ForBaseItem(
|
||||||
|
fromLongClick = true,
|
||||||
|
item = item,
|
||||||
|
chosenStreams = null,
|
||||||
|
showGoTo = true,
|
||||||
|
showStreamChoices = false,
|
||||||
|
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||||
|
canRemoveContinueWatching = false,
|
||||||
|
canRemoveNextUp = false,
|
||||||
|
actions = contextActions,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClickPlayAll =
|
||||||
|
actions.onClickPlayAll ?: { shuffle ->
|
||||||
|
itemId.toUUIDOrNull()?.let {
|
||||||
|
val destination =
|
||||||
|
if (item?.type == BaseItemKind.PHOTO_ALBUM) {
|
||||||
|
Destination.Slideshow(
|
||||||
|
parentId = it,
|
||||||
|
index = 0,
|
||||||
|
filter = CollectionFolderFilter(filter = filter),
|
||||||
|
sortAndDirection = sortAndDirection,
|
||||||
|
recursive = true,
|
||||||
|
startSlideshow = true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Destination.PlaybackList(
|
||||||
|
itemId = it,
|
||||||
|
startIndex = 0,
|
||||||
|
shuffle = shuffle,
|
||||||
|
recursive = recursive,
|
||||||
|
sortAndDirection = sortAndDirection,
|
||||||
|
filter = filter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModel.navigateTo(destination)
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
},
|
||||||
|
onClickPlayRemoteButton =
|
||||||
|
actions.onClickPlayRemoteButton ?: { index, item ->
|
||||||
|
val destination =
|
||||||
|
if (item.type == BaseItemKind.PHOTO_ALBUM) {
|
||||||
|
Destination.Slideshow(
|
||||||
|
parentId = item.id,
|
||||||
|
index = index,
|
||||||
|
filter = CollectionFolderFilter(filter = filter),
|
||||||
|
sortAndDirection = sortAndDirection,
|
||||||
|
recursive = true,
|
||||||
|
startSlideshow = true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Destination.Playback(item)
|
||||||
|
}
|
||||||
|
viewModel.navigateTo(destination)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
DataLoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
DataLoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
|
|
@ -662,10 +797,8 @@ fun CollectionFolderGrid(
|
||||||
sortAndDirection = sortAndDirection!!,
|
sortAndDirection = sortAndDirection!!,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||||
onClickItem = onClickItem,
|
onClickItem = gridActions.onClickItem,
|
||||||
onLongClickItem = { position, item ->
|
onLongClickItem = gridActions.onLongClickItem!!,
|
||||||
moreDialog.makePresent(PositionItem(position, item))
|
|
||||||
},
|
|
||||||
onSortChange = {
|
onSortChange = {
|
||||||
viewModel.onSortChange(it, recursive, filter)
|
viewModel.onSortChange(it, recursive, filter)
|
||||||
},
|
},
|
||||||
|
|
@ -689,47 +822,8 @@ fun CollectionFolderGrid(
|
||||||
onSaveViewOptions = { viewModel.saveViewOptions(it) },
|
onSaveViewOptions = { viewModel.saveViewOptions(it) },
|
||||||
onChangeBackdrop = viewModel::updateBackdrop,
|
onChangeBackdrop = viewModel::updateBackdrop,
|
||||||
playEnabled = playEnabled,
|
playEnabled = playEnabled,
|
||||||
onClickPlay = { index, item ->
|
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||||
val destination =
|
onClickPlayAll = gridActions.onClickPlayAll!!,
|
||||||
if (item.type == BaseItemKind.PHOTO_ALBUM) {
|
|
||||||
Destination.Slideshow(
|
|
||||||
parentId = item.id,
|
|
||||||
index = index,
|
|
||||||
filter = CollectionFolderFilter(filter = filter),
|
|
||||||
sortAndDirection = sortAndDirection,
|
|
||||||
recursive = true,
|
|
||||||
startSlideshow = true,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Destination.Playback(item)
|
|
||||||
}
|
|
||||||
viewModel.navigateTo(destination)
|
|
||||||
},
|
|
||||||
onClickPlayAll = { shuffle ->
|
|
||||||
itemId.toUUIDOrNull()?.let {
|
|
||||||
val destination =
|
|
||||||
if (item?.type == BaseItemKind.PHOTO_ALBUM) {
|
|
||||||
Destination.Slideshow(
|
|
||||||
parentId = it,
|
|
||||||
index = 0,
|
|
||||||
filter = CollectionFolderFilter(filter = filter),
|
|
||||||
sortAndDirection = sortAndDirection,
|
|
||||||
recursive = true,
|
|
||||||
startSlideshow = true,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Destination.PlaybackList(
|
|
||||||
itemId = it,
|
|
||||||
startIndex = 0,
|
|
||||||
shuffle = shuffle,
|
|
||||||
recursive = recursive,
|
|
||||||
sortAndDirection = sortAndDirection,
|
|
||||||
filter = filter,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
viewModel.navigateTo(destination)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
|
|
@ -751,47 +845,22 @@ fun CollectionFolderGrid(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
moreDialog.compose { (position, item) ->
|
overviewDialog?.let { info ->
|
||||||
DialogPopup(
|
ItemDetailsDialog(
|
||||||
showDialog = true,
|
info = info,
|
||||||
title = item.title ?: "",
|
showFilePath =
|
||||||
dialogItems =
|
viewModel.serverRepository.currentUserDto.value
|
||||||
buildMoreDialogItemsForHome(
|
?.policy
|
||||||
context = context,
|
?.isAdministrator == true,
|
||||||
item = item,
|
onDismissRequest = { overviewDialog = null },
|
||||||
seriesId = null,
|
)
|
||||||
playbackPosition = item.playbackPosition,
|
}
|
||||||
watched = item.played,
|
showContextMenu?.let { contextMenu ->
|
||||||
favorite = item.favorite,
|
ContextMenuDialog(
|
||||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
onDismissRequest = { showContextMenu = null },
|
||||||
actions =
|
getMediaSource = null,
|
||||||
MoreDialogActions(
|
contextMenu = contextMenu,
|
||||||
navigateTo = { viewModel.navigateTo(it) },
|
preferredSubtitleLanguage = null,
|
||||||
onClickWatch = { itemId, watched ->
|
|
||||||
viewModel.setWatched(position, itemId, watched)
|
|
||||||
},
|
|
||||||
onClickFavorite = { itemId, watched ->
|
|
||||||
viewModel.setFavorite(position, itemId, watched)
|
|
||||||
},
|
|
||||||
onClickAddPlaylist = {
|
|
||||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
|
||||||
showPlaylistDialog.makePresent(it)
|
|
||||||
},
|
|
||||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
|
||||||
onClickDelete = {
|
|
||||||
showDeleteDialog = PositionItem(position, item)
|
|
||||||
},
|
|
||||||
onClickGoTo = {
|
|
||||||
onClickItem.invoke(position, it)
|
|
||||||
},
|
|
||||||
onClickAddToQueue = {
|
|
||||||
viewModel.addToQueue(it, 0)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onDismissRequest = { moreDialog.makeAbsent() },
|
|
||||||
dismissOnClick = true,
|
|
||||||
waitToLoad = true,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showPlaylistDialog.compose { itemId ->
|
showPlaylistDialog.compose { itemId ->
|
||||||
|
|
@ -811,16 +880,6 @@ fun CollectionFolderGrid(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showDeleteDialog?.let { (position, item) ->
|
|
||||||
ConfirmDeleteDialog(
|
|
||||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
|
||||||
onCancel = { showDeleteDialog = null },
|
|
||||||
onConfirm = {
|
|
||||||
viewModel.deleteItem(position, item)
|
|
||||||
showDeleteDialog = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
|
@ -1002,7 +1061,7 @@ fun CollectionFolderGridContent(
|
||||||
position = newPosition
|
position = newPosition
|
||||||
positionCallback?.invoke(columns, newPosition)
|
positionCallback?.invoke(columns, newPosition)
|
||||||
},
|
},
|
||||||
cardContent = { item, onClick, onLongClick, mod ->
|
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||||
GridCard(
|
GridCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
@ -1011,6 +1070,7 @@ fun CollectionFolderGridContent(
|
||||||
imageAspectRatio = viewOptions.aspectRatio.ratio,
|
imageAspectRatio = viewOptions.aspectRatio.ratio,
|
||||||
imageType = viewOptions.imageType,
|
imageType = viewOptions.imageType,
|
||||||
showTitle = viewOptions.showTitles,
|
showTitle = viewOptions.showTitles,
|
||||||
|
fillWidth = widthPx,
|
||||||
modifier = mod,
|
modifier = mod,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -1051,17 +1111,13 @@ data class PositionItem(
|
||||||
data class CollectionFolderGridParameters(
|
data class CollectionFolderGridParameters(
|
||||||
val columns: Int = 6,
|
val columns: Int = 6,
|
||||||
val spacing: Dp = 16.dp,
|
val spacing: Dp = 16.dp,
|
||||||
val cardContent: @Composable (
|
val cardContent: @Composable (GridItemDetails<BaseItem>) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||||
item: BaseItem?,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onLongClick: () -> Unit,
|
|
||||||
mod: Modifier,
|
|
||||||
) -> Unit = { item, onClick, onLongClick, mod ->
|
|
||||||
GridCard(
|
GridCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
imageContentScale = ContentScale.FillBounds,
|
imageContentScale = ContentScale.FillBounds,
|
||||||
|
fillWidth = widthPx,
|
||||||
modifier = mod,
|
modifier = mod,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -1071,13 +1127,14 @@ data class CollectionFolderGridParameters(
|
||||||
CollectionFolderGridParameters(
|
CollectionFolderGridParameters(
|
||||||
columns = 6,
|
columns = 6,
|
||||||
spacing = 16.dp,
|
spacing = 16.dp,
|
||||||
cardContent = { item, onClick, onLongClick, mod ->
|
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||||
GridCard(
|
GridCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
imageContentScale = ContentScale.FillBounds,
|
imageContentScale = ContentScale.FillBounds,
|
||||||
imageAspectRatio = AspectRatios.TALL,
|
imageAspectRatio = AspectRatios.TALL,
|
||||||
|
fillWidth = widthPx,
|
||||||
modifier = mod,
|
modifier = mod,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -1086,13 +1143,14 @@ data class CollectionFolderGridParameters(
|
||||||
CollectionFolderGridParameters(
|
CollectionFolderGridParameters(
|
||||||
columns = 4,
|
columns = 4,
|
||||||
spacing = 24.dp,
|
spacing = 24.dp,
|
||||||
cardContent = { item, onClick, onLongClick, mod ->
|
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||||
GridCard(
|
GridCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
imageContentScale = ContentScale.Crop,
|
imageContentScale = ContentScale.Crop,
|
||||||
imageAspectRatio = AspectRatios.WIDE,
|
imageAspectRatio = AspectRatios.WIDE,
|
||||||
|
fillWidth = widthPx,
|
||||||
modifier = mod,
|
modifier = mod,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -1101,13 +1159,14 @@ data class CollectionFolderGridParameters(
|
||||||
CollectionFolderGridParameters(
|
CollectionFolderGridParameters(
|
||||||
columns = 6,
|
columns = 6,
|
||||||
spacing = 16.dp,
|
spacing = 16.dp,
|
||||||
cardContent = { item, onClick, onLongClick, mod ->
|
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||||
GridCard(
|
GridCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
imageContentScale = ContentScale.FillBounds,
|
imageContentScale = ContentScale.FillBounds,
|
||||||
imageAspectRatio = AspectRatios.SQUARE,
|
imageAspectRatio = AspectRatios.SQUARE,
|
||||||
|
fillWidth = widthPx,
|
||||||
modifier = mod,
|
modifier = mod,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -1165,3 +1224,10 @@ fun GridTitle(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class GridClickActions(
|
||||||
|
val onClickItem: (Int, BaseItem) -> Unit,
|
||||||
|
val onLongClickItem: ((Int, BaseItem) -> Unit)? = null,
|
||||||
|
val onClickPlayAll: ((shuffle: Boolean) -> Unit)? = null,
|
||||||
|
val onClickPlayRemoteButton: ((Int, BaseItem) -> Unit)? = null,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,915 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
|
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.util.supportedPlayableTypes
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
sealed interface ContextMenu {
|
||||||
|
data class ForBaseItem(
|
||||||
|
val fromLongClick: Boolean,
|
||||||
|
val item: BaseItem,
|
||||||
|
val chosenStreams: ChosenStreams?,
|
||||||
|
val showGoTo: Boolean,
|
||||||
|
val showStreamChoices: Boolean,
|
||||||
|
val canDelete: Boolean,
|
||||||
|
val canRemoveContinueWatching: Boolean,
|
||||||
|
val canRemoveNextUp: Boolean,
|
||||||
|
val actions: ContextMenuActions,
|
||||||
|
) : ContextMenu
|
||||||
|
|
||||||
|
data class ForPerson(
|
||||||
|
val fromLongClick: Boolean,
|
||||||
|
val person: Person,
|
||||||
|
val actions: PersonContextActions,
|
||||||
|
) : ContextMenu
|
||||||
|
|
||||||
|
data class ForMusic(
|
||||||
|
val fromLongClick: Boolean,
|
||||||
|
val item: BaseItem,
|
||||||
|
val index: Int,
|
||||||
|
val canDelete: Boolean,
|
||||||
|
val canRemoveFromQueue: Boolean,
|
||||||
|
val actions: MusicContextActions,
|
||||||
|
) : ContextMenu
|
||||||
|
|
||||||
|
data class ForQueue(
|
||||||
|
val fromLongClick: Boolean,
|
||||||
|
val item: AudioItem,
|
||||||
|
val index: Int,
|
||||||
|
val actions: QueueContextActions,
|
||||||
|
) : ContextMenu
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ContextMenuActions(
|
||||||
|
val navigateTo: (Destination) -> Unit,
|
||||||
|
val onShowOverview: (BaseItem) -> Unit,
|
||||||
|
val onClickWatch: (UUID, Boolean) -> Unit,
|
||||||
|
val onClickFavorite: (UUID, Boolean) -> Unit,
|
||||||
|
val onClickAddPlaylist: (UUID) -> Unit,
|
||||||
|
val onSendMediaInfo: (UUID) -> Unit,
|
||||||
|
val onDeleteItem: (BaseItem) -> Unit,
|
||||||
|
val onChooseVersion: (BaseItem, MediaSourceInfo) -> Unit,
|
||||||
|
val onChooseTracks: (ChosenTrackResult) -> Unit,
|
||||||
|
val onClearChosenStreams: (ChosenStreams?) -> Unit,
|
||||||
|
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
|
||||||
|
val onClickRemoveFromNextUp: (BaseItem) -> Unit = {},
|
||||||
|
val onClickAddToQueue: (BaseItem) -> Unit = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PersonContextActions(
|
||||||
|
val navigateTo: (Destination) -> Unit,
|
||||||
|
val onClickFavorite: (UUID, Boolean) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MusicContextActions(
|
||||||
|
val navigateTo: (Destination) -> Unit,
|
||||||
|
val onClickPlay: (Int, BaseItem) -> Unit,
|
||||||
|
val onClickPlayNext: (Int, BaseItem) -> Unit,
|
||||||
|
val onClickFavorite: (UUID, Boolean) -> Unit,
|
||||||
|
val onClickAddPlaylist: (UUID) -> Unit,
|
||||||
|
val onDeleteItem: (BaseItem) -> Unit,
|
||||||
|
val onClickAddToQueue: (BaseItem) -> Unit,
|
||||||
|
val onClickRemoveFromQueue: (Int, BaseItem) -> Unit,
|
||||||
|
val onClickGoToAlbum: (UUID) -> Unit = {
|
||||||
|
navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
|
||||||
|
},
|
||||||
|
val onClickGoToArtist: (UUID) -> Unit = {
|
||||||
|
navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
data class QueueContextActions(
|
||||||
|
val onNavigate: (Destination) -> Unit,
|
||||||
|
val onClickPlay: (Int, AudioItem) -> Unit,
|
||||||
|
val onClickPlayNext: (Int, AudioItem) -> Unit,
|
||||||
|
val onClickGoToAlbum: (java.util.UUID) -> Unit = {
|
||||||
|
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
|
||||||
|
},
|
||||||
|
val onClickGoToArtist: (java.util.UUID) -> Unit = {
|
||||||
|
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
|
||||||
|
},
|
||||||
|
val onClickRemoveFromQueue: (Int, AudioItem) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChosenTrackResult(
|
||||||
|
val item: BaseItem,
|
||||||
|
val streamType: MediaStreamType,
|
||||||
|
val trackIndex: Int,
|
||||||
|
val itemPlayback: ItemPlayback?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContextMenuDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
contextMenu: ContextMenu,
|
||||||
|
getMediaSource: ((dto: BaseItemDto, itemPlayback: ItemPlayback?) -> MediaSourceInfo?)?,
|
||||||
|
preferredSubtitleLanguage: String?,
|
||||||
|
) {
|
||||||
|
when (contextMenu) {
|
||||||
|
is ContextMenu.ForBaseItem -> {
|
||||||
|
ContextMenu(
|
||||||
|
onDismissRequest,
|
||||||
|
contextMenu,
|
||||||
|
getMediaSource,
|
||||||
|
preferredSubtitleLanguage,
|
||||||
|
contextMenu.actions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ContextMenu.ForPerson -> {
|
||||||
|
ContextMenu(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
item = contextMenu,
|
||||||
|
actions = contextMenu.actions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ContextMenu.ForMusic -> {
|
||||||
|
ContextMenu(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
item = contextMenu,
|
||||||
|
actions = contextMenu.actions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ContextMenu.ForQueue -> {
|
||||||
|
ContextMenu(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
item = contextMenu,
|
||||||
|
actions = contextMenu.actions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContextMenu(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
contextMenu: ContextMenu.ForBaseItem,
|
||||||
|
getMediaSource: ((dto: BaseItemDto, itemPlayback: ItemPlayback?) -> MediaSourceInfo?)?,
|
||||||
|
preferredSubtitleLanguage: String?,
|
||||||
|
actions: ContextMenuActions,
|
||||||
|
) {
|
||||||
|
val item = contextMenu.item
|
||||||
|
val chosenStreams = contextMenu.chosenStreams
|
||||||
|
val context = LocalContext.current
|
||||||
|
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
var showPlayWithDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val dialogItems =
|
||||||
|
remember(context, item, chosenStreams) {
|
||||||
|
buildContextMenuItems(
|
||||||
|
context = context,
|
||||||
|
item = item,
|
||||||
|
watched = item.played,
|
||||||
|
favorite = item.favorite,
|
||||||
|
seriesId = item.data.seriesId,
|
||||||
|
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||||
|
canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
|
||||||
|
showGoTo = contextMenu.showGoTo,
|
||||||
|
showStreamChoices = contextMenu.showStreamChoices,
|
||||||
|
canDelete = contextMenu.canDelete,
|
||||||
|
canRemoveContinueWatching = contextMenu.canRemoveContinueWatching,
|
||||||
|
canRemoveNextUp = contextMenu.canRemoveNextUp,
|
||||||
|
actions = actions,
|
||||||
|
onChooseVersion = {
|
||||||
|
chooseVersion =
|
||||||
|
chooseVersionParams(
|
||||||
|
context,
|
||||||
|
item.data.mediaSources.orEmpty(),
|
||||||
|
chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||||
|
) { idx ->
|
||||||
|
val source = item.data.mediaSources!![idx]
|
||||||
|
actions.onChooseVersion.invoke(item, source)
|
||||||
|
onDismissRequest.invoke()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChooseTracks = { type ->
|
||||||
|
getMediaSource
|
||||||
|
?.invoke(
|
||||||
|
item.data,
|
||||||
|
chosenStreams?.itemPlayback,
|
||||||
|
)?.let { source ->
|
||||||
|
chooseVersion =
|
||||||
|
chooseStream(
|
||||||
|
context = context,
|
||||||
|
streams = source.mediaStreams.orEmpty(),
|
||||||
|
type = type,
|
||||||
|
currentIndex =
|
||||||
|
if (type == MediaStreamType.AUDIO) {
|
||||||
|
chosenStreams?.audioStream?.index
|
||||||
|
} else {
|
||||||
|
chosenStreams?.subtitleStream?.index
|
||||||
|
},
|
||||||
|
onClick = { trackIndex ->
|
||||||
|
actions.onChooseTracks.invoke(
|
||||||
|
ChosenTrackResult(
|
||||||
|
item = item,
|
||||||
|
streamType = type,
|
||||||
|
trackIndex = trackIndex,
|
||||||
|
itemPlayback = chosenStreams?.itemPlayback,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
onDismissRequest.invoke()
|
||||||
|
},
|
||||||
|
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShowOverview = { actions.onShowOverview.invoke(item) },
|
||||||
|
onClearChosenStreams = {
|
||||||
|
actions.onClearChosenStreams.invoke(chosenStreams)
|
||||||
|
},
|
||||||
|
onClickDelete = { showDeleteDialog = true },
|
||||||
|
onClickPlayWith = { showPlayWithDialog = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = item.title ?: "",
|
||||||
|
dialogItems = dialogItems,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
dismissOnClick = false,
|
||||||
|
waitToLoad = contextMenu.fromLongClick,
|
||||||
|
)
|
||||||
|
if (chooseVersion != null) {
|
||||||
|
chooseVersion?.let { params ->
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = params.title,
|
||||||
|
dialogItems = params.items,
|
||||||
|
onDismissRequest = { chooseVersion = null },
|
||||||
|
dismissOnClick = true,
|
||||||
|
waitToLoad = params.fromLongClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showDeleteDialog) {
|
||||||
|
ConfirmDeleteDialog(
|
||||||
|
itemTitle = item.title ?: "",
|
||||||
|
onCancel = { showDeleteDialog = false },
|
||||||
|
onConfirm = {
|
||||||
|
actions.onDeleteItem.invoke(item)
|
||||||
|
onDismissRequest.invoke()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showPlayWithDialog) {
|
||||||
|
val dialogItems =
|
||||||
|
remember {
|
||||||
|
buildPlayWith(context) { transcode, backend ->
|
||||||
|
onDismissRequest.invoke()
|
||||||
|
actions.navigateTo(
|
||||||
|
Destination.Playback(
|
||||||
|
itemId = item.id,
|
||||||
|
positionMs = item.resumeMs,
|
||||||
|
forceTranscoding = transcode,
|
||||||
|
backend = backend,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = stringResource(R.string.play_with),
|
||||||
|
dialogItems = dialogItems,
|
||||||
|
onDismissRequest = { showPlayWithDialog = false },
|
||||||
|
dismissOnClick = true,
|
||||||
|
waitToLoad = false,
|
||||||
|
elevation = 3.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildContextMenuItems(
|
||||||
|
context: Context,
|
||||||
|
item: BaseItem,
|
||||||
|
seriesId: UUID?,
|
||||||
|
sourceId: UUID?,
|
||||||
|
watched: Boolean,
|
||||||
|
favorite: Boolean,
|
||||||
|
canClearChosenStreams: Boolean,
|
||||||
|
showGoTo: Boolean,
|
||||||
|
showStreamChoices: Boolean,
|
||||||
|
canDelete: Boolean,
|
||||||
|
canRemoveContinueWatching: Boolean,
|
||||||
|
canRemoveNextUp: Boolean,
|
||||||
|
actions: ContextMenuActions,
|
||||||
|
onChooseVersion: () -> Unit,
|
||||||
|
onChooseTracks: (MediaStreamType) -> Unit,
|
||||||
|
onShowOverview: () -> Unit,
|
||||||
|
onClearChosenStreams: () -> Unit,
|
||||||
|
onClickDelete: () -> Unit,
|
||||||
|
onClickPlayWith: () -> Unit,
|
||||||
|
): List<DialogItem> =
|
||||||
|
buildList {
|
||||||
|
if (showGoTo) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.go_to),
|
||||||
|
Icons.Default.ArrowForward,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickGoTo(item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.type in supportedPlayableTypes) {
|
||||||
|
if (item.playbackPosition > Duration.ZERO) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.resume),
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
iconColor = Color.Green.copy(alpha = .8f),
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.navigateTo(
|
||||||
|
Destination.Playback(
|
||||||
|
item.id,
|
||||||
|
item.playbackPosition.inWholeMilliseconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.restart),
|
||||||
|
Icons.Default.Refresh,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.navigateTo(
|
||||||
|
Destination.Playback(
|
||||||
|
item.id,
|
||||||
|
0L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.play),
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
iconColor = Color.Green.copy(alpha = .8f),
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.navigateTo(
|
||||||
|
Destination.Playback(
|
||||||
|
item.id,
|
||||||
|
0L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showStreamChoices) {
|
||||||
|
item.data.mediaSources?.letNotEmpty { sources ->
|
||||||
|
val source =
|
||||||
|
sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } }
|
||||||
|
?: sources.firstOrNull()
|
||||||
|
source?.mediaStreams?.letNotEmpty { streams ->
|
||||||
|
val audioCount = streams.count { it.type == MediaStreamType.AUDIO }
|
||||||
|
val subtitleCount = streams.count { it.type == MediaStreamType.SUBTITLE }
|
||||||
|
if (audioCount > 1) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(
|
||||||
|
R.string.choose_stream,
|
||||||
|
context.getString(R.string.audio),
|
||||||
|
),
|
||||||
|
R.string.fa_volume_low,
|
||||||
|
dismissOnClick = false,
|
||||||
|
) {
|
||||||
|
onChooseTracks.invoke(MediaStreamType.AUDIO)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (subtitleCount > 0) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(
|
||||||
|
R.string.choose_stream,
|
||||||
|
context.getString(R.string.subtitles),
|
||||||
|
),
|
||||||
|
R.string.fa_closed_captioning,
|
||||||
|
dismissOnClick = false,
|
||||||
|
) {
|
||||||
|
onChooseTracks.invoke(MediaStreamType.SUBTITLE)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sources.size > 1) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(
|
||||||
|
R.string.choose_stream,
|
||||||
|
context.getString(R.string.version),
|
||||||
|
),
|
||||||
|
R.string.fa_file_video,
|
||||||
|
dismissOnClick = false,
|
||||||
|
) {
|
||||||
|
onChooseVersion.invoke()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.type == BaseItemKind.MUSIC_ALBUM) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.add_to_queue),
|
||||||
|
Icons.Default.Add,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickAddToQueue(item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
text = R.string.add_to_playlist,
|
||||||
|
iconStringRes = R.string.fa_list_ul,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickAddPlaylist.invoke(item.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (canDelete) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.delete),
|
||||||
|
Icons.Default.Delete,
|
||||||
|
iconColor = Color.Red.copy(alpha = .8f),
|
||||||
|
dismissOnClick = false,
|
||||||
|
) {
|
||||||
|
onClickDelete.invoke()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
text = R.string.remove_continue_watching,
|
||||||
|
iconStringRes = R.string.fa_eye,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickWatch.invoke(item.id, false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
text = R.string.remove_next_up,
|
||||||
|
iconStringRes = R.string.fa_tag,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickRemoveFromNextUp.invoke(item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||||
|
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickWatch.invoke(item.id, !watched)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
|
||||||
|
iconStringRes = R.string.fa_heart,
|
||||||
|
iconColor = if (favorite) Color.Red else Color.Unspecified,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickFavorite.invoke(item.id, !favorite)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
item.data.albumId?.let { albumId ->
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.go_to_album),
|
||||||
|
R.string.fa_compact_disc,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.navigateTo(
|
||||||
|
Destination.MediaItem(
|
||||||
|
albumId,
|
||||||
|
BaseItemKind.MUSIC_ALBUM,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item.data.artistItems?.firstOrNull()?.id?.let { artistId ->
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.go_to_artist),
|
||||||
|
R.string.fa_user,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.navigateTo(
|
||||||
|
Destination.MediaItem(
|
||||||
|
artistId,
|
||||||
|
BaseItemKind.MUSIC_ARTIST,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
seriesId?.let {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.go_to_series),
|
||||||
|
Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.navigateTo(
|
||||||
|
Destination.MediaItem(
|
||||||
|
seriesId,
|
||||||
|
BaseItemKind.SERIES,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.data.mediaSources?.isNotEmpty() == true) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.media_information),
|
||||||
|
Icons.Default.Info,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
onShowOverview.invoke()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showStreamChoices && canClearChosenStreams) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.clear_track_choices),
|
||||||
|
Icons.Default.Delete,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
onClearChosenStreams()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.type in supportedPlayableTypes) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.play_with),
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
dismissOnClick = false,
|
||||||
|
onClick = onClickPlayWith,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.data.mediaSources?.isNotEmpty() == true) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
text = R.string.send_media_info_log_to_server,
|
||||||
|
iconStringRes = R.string.fa_file_video,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onSendMediaInfo.invoke(item.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPlayWith(
|
||||||
|
context: Context,
|
||||||
|
onClick: (Boolean, PlayerBackend?) -> Unit,
|
||||||
|
) = buildList {
|
||||||
|
val entries =
|
||||||
|
PlayerBackend.entries
|
||||||
|
.filterNot { it == PlayerBackend.UNRECOGNIZED }
|
||||||
|
.zip(context.resources.getStringArray(R.array.player_backend_options))
|
||||||
|
.filterNot { it.first == PlayerBackend.PREFER_MPV }
|
||||||
|
entries.forEach { (backend, title) ->
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
title,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
onClick.invoke(false, backend)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.transcoding),
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
onClick.invoke(true, null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContextMenu(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
item: ContextMenu.ForPerson,
|
||||||
|
actions: PersonContextActions,
|
||||||
|
) {
|
||||||
|
val person = item.person
|
||||||
|
val dialogItems =
|
||||||
|
buildList {
|
||||||
|
val itemId = person.id
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
stringResource(R.string.go_to),
|
||||||
|
Icons.Default.ArrowForward,
|
||||||
|
) {
|
||||||
|
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
text = if (person.favorite) R.string.remove_favorite else R.string.add_favorite,
|
||||||
|
iconStringRes = R.string.fa_heart,
|
||||||
|
iconColor = if (person.favorite) Color.Red else Color.Unspecified,
|
||||||
|
) {
|
||||||
|
actions.onClickFavorite.invoke(itemId, !person.favorite)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = person.name ?: "",
|
||||||
|
dialogItems = dialogItems,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
dismissOnClick = true,
|
||||||
|
waitToLoad = item.fromLongClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContextMenu(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
item: ContextMenu.ForMusic,
|
||||||
|
actions: MusicContextActions,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
val dialogItems =
|
||||||
|
remember(context, item, actions) {
|
||||||
|
buildContextForMusic(context, item, actions, onClickDelete = {
|
||||||
|
showDeleteDialog = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = item.item.title ?: "",
|
||||||
|
dialogItems = dialogItems,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
dismissOnClick = false,
|
||||||
|
waitToLoad = item.fromLongClick,
|
||||||
|
)
|
||||||
|
if (showDeleteDialog) {
|
||||||
|
ConfirmDeleteDialog(
|
||||||
|
itemTitle = item.item.title ?: "",
|
||||||
|
onCancel = { showDeleteDialog = false },
|
||||||
|
onConfirm = {
|
||||||
|
actions.onDeleteItem.invoke(item.item)
|
||||||
|
onDismissRequest.invoke()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildContextForMusic(
|
||||||
|
context: Context,
|
||||||
|
music: ContextMenu.ForMusic,
|
||||||
|
actions: MusicContextActions,
|
||||||
|
onClickDelete: () -> Unit,
|
||||||
|
): List<DialogItem> =
|
||||||
|
buildList {
|
||||||
|
val item = music.item
|
||||||
|
val index = music.index
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.play),
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
iconColor = Color.Green.copy(alpha = .8f),
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickPlay(index, item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.play_next),
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
iconColor = Color.Green.copy(alpha = .8f),
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickPlayNext(index, item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (music.canRemoveFromQueue) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.remove_from_queue),
|
||||||
|
Icons.Default.Delete,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickRemoveFromQueue(index, item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.add_to_queue),
|
||||||
|
Icons.Default.Add,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickAddToQueue(item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
text = R.string.add_to_playlist,
|
||||||
|
iconStringRes = R.string.fa_list_ul,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickAddPlaylist.invoke(item.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (music.canDelete) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.delete),
|
||||||
|
Icons.Default.Delete,
|
||||||
|
iconColor = Color.Red.copy(alpha = .8f),
|
||||||
|
dismissOnClick = false,
|
||||||
|
) {
|
||||||
|
onClickDelete.invoke()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
text = if (item.favorite) R.string.remove_favorite else R.string.add_favorite,
|
||||||
|
iconStringRes = R.string.fa_heart,
|
||||||
|
iconColor = if (item.favorite) Color.Red else Color.Unspecified,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickFavorite.invoke(item.id, !item.favorite)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.go_to_album),
|
||||||
|
R.string.fa_compact_disc,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickGoToAlbum.invoke(item.data.albumId!!)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.go_to_artist),
|
||||||
|
R.string.fa_user,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickGoToArtist.invoke(
|
||||||
|
item.data.artistItems!!
|
||||||
|
.first()
|
||||||
|
.id,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContextMenu(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
item: ContextMenu.ForQueue,
|
||||||
|
actions: QueueContextActions,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val dialogItems =
|
||||||
|
remember(context, item, actions) {
|
||||||
|
buildContextForMusicQueue(
|
||||||
|
context,
|
||||||
|
item.item,
|
||||||
|
item.index,
|
||||||
|
actions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = item.item.title ?: "",
|
||||||
|
dialogItems = dialogItems,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
dismissOnClick = false,
|
||||||
|
waitToLoad = item.fromLongClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildContextForMusicQueue(
|
||||||
|
context: Context,
|
||||||
|
item: AudioItem,
|
||||||
|
index: Int,
|
||||||
|
actions: QueueContextActions,
|
||||||
|
): List<DialogItem> =
|
||||||
|
buildList {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.play),
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
iconColor = Color.Green.copy(alpha = .8f),
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickPlay(index, item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.play_next),
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
iconColor = Color.Green.copy(alpha = .8f),
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickPlayNext(index, item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.remove_from_queue),
|
||||||
|
Icons.Default.Delete,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickRemoveFromQueue(index, item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (item.albumId != null) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.go_to_album),
|
||||||
|
Icons.Default.ArrowForward,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickGoToAlbum.invoke(item.albumId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.artistId != null) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.go_to_artist),
|
||||||
|
Icons.Default.ArrowForward,
|
||||||
|
dismissOnClick = true,
|
||||||
|
) {
|
||||||
|
actions.onClickGoToArtist.invoke(item.artistId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -104,11 +104,13 @@ data class DialogItem(
|
||||||
val trailingContent: @Composable (() -> Unit)? = null,
|
val trailingContent: @Composable (() -> Unit)? = null,
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
val selected: Boolean = false,
|
val selected: Boolean = false,
|
||||||
|
val dismissOnClick: Boolean = false,
|
||||||
) : DialogItemEntry {
|
) : DialogItemEntry {
|
||||||
constructor(
|
constructor(
|
||||||
@StringRes text: Int,
|
@StringRes text: Int,
|
||||||
@StringRes iconStringRes: Int,
|
@StringRes iconStringRes: Int,
|
||||||
iconColor: Color = Color.Unspecified,
|
iconColor: Color = Color.Unspecified,
|
||||||
|
dismissOnClick: Boolean = false,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) : this(
|
) : this(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
|
|
@ -124,11 +126,13 @@ data class DialogItem(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
dismissOnClick = dismissOnClick,
|
||||||
)
|
)
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
text: String,
|
text: String,
|
||||||
@StringRes iconStringRes: Int,
|
@StringRes iconStringRes: Int,
|
||||||
|
dismissOnClick: Boolean = false,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) : this(
|
) : this(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
|
|
@ -145,12 +149,14 @@ data class DialogItem(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
dismissOnClick = dismissOnClick,
|
||||||
)
|
)
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
text: String,
|
text: String,
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
iconColor: Color? = null,
|
iconColor: Color? = null,
|
||||||
|
dismissOnClick: Boolean = false,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) : this(
|
) : this(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
|
|
@ -167,10 +173,12 @@ data class DialogItem(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
dismissOnClick = dismissOnClick,
|
||||||
)
|
)
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
text: String,
|
text: String,
|
||||||
|
dismissOnClick: Boolean = false,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) : this(
|
) : this(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
|
|
@ -180,6 +188,7 @@ data class DialogItem(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
dismissOnClick = dismissOnClick,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -201,7 +210,7 @@ fun DialogPopup(
|
||||||
dismissOnClick: Boolean = true,
|
dismissOnClick: Boolean = true,
|
||||||
waitToLoad: Boolean = true,
|
waitToLoad: Boolean = true,
|
||||||
properties: DialogProperties = DialogProperties(),
|
properties: DialogProperties = DialogProperties(),
|
||||||
elevation: Dp = 8.dp,
|
elevation: Dp = 1.dp,
|
||||||
) {
|
) {
|
||||||
var waiting by remember { mutableStateOf(waitToLoad) }
|
var waiting by remember { mutableStateOf(waitToLoad) }
|
||||||
if (showDialog) {
|
if (showDialog) {
|
||||||
|
|
@ -296,7 +305,7 @@ fun DialogPopupContent(
|
||||||
selected = item.selected,
|
selected = item.selected,
|
||||||
enabled = !waiting && item.enabled,
|
enabled = !waiting && item.enabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (dismissOnClick) {
|
if (dismissOnClick || item.dismissOnClick) {
|
||||||
onDismissRequest.invoke()
|
onDismissRequest.invoke()
|
||||||
}
|
}
|
||||||
item.onClick.invoke()
|
item.onClick.invoke()
|
||||||
|
|
@ -349,7 +358,7 @@ fun DialogPopup(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
dismissOnClick: Boolean = true,
|
dismissOnClick: Boolean = true,
|
||||||
properties: DialogProperties = DialogProperties(),
|
properties: DialogProperties = DialogProperties(),
|
||||||
elevation: Dp = 8.dp,
|
elevation: Dp = 1.dp,
|
||||||
) = DialogPopup(
|
) = DialogPopup(
|
||||||
showDialog = true,
|
showDialog = true,
|
||||||
waitToLoad = params.fromLongClick,
|
waitToLoad = params.fromLongClick,
|
||||||
|
|
@ -427,7 +436,7 @@ fun ScrollableDialog(
|
||||||
fun BasicDialog(
|
fun BasicDialog(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
properties: DialogProperties = DialogProperties(),
|
properties: DialogProperties = DialogProperties(),
|
||||||
elevation: Dp = 8.dp,
|
elevation: Dp = 1.dp,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
Dialog(
|
Dialog(
|
||||||
|
|
@ -458,7 +467,7 @@ fun ConfirmDialog(
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
properties: DialogProperties = DialogProperties(),
|
properties: DialogProperties = DialogProperties(),
|
||||||
elevation: Dp = 8.dp,
|
elevation: Dp = 1.dp,
|
||||||
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||||
) = BasicDialog(
|
) = BasicDialog(
|
||||||
onDismissRequest = onCancel,
|
onDismissRequest = onCancel,
|
||||||
|
|
@ -527,10 +536,12 @@ fun ConfirmDeleteDialog(
|
||||||
itemTitle: String,
|
itemTitle: String,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
|
elevation: Dp = 3.dp,
|
||||||
) {
|
) {
|
||||||
BasicDialog(
|
BasicDialog(
|
||||||
onDismissRequest = onCancel,
|
onDismissRequest = onCancel,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
elevation = elevation,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
|
|
||||||
|
|
@ -309,7 +309,7 @@ fun GenreCardGrid(
|
||||||
},
|
},
|
||||||
columns = columns,
|
columns = columns,
|
||||||
spacing = spacing,
|
spacing = spacing,
|
||||||
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||||
GenreCard(
|
GenreCard(
|
||||||
genre = item,
|
genre = item,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An invisible, no size Box which can be focused.
|
||||||
|
*
|
||||||
|
* Used to allow for transitioning to restore hidden content
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun HiddenFocusBox(
|
||||||
|
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||||
|
onFocus: () -> Unit,
|
||||||
|
) = Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(1.dp)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) onFocus.invoke()
|
||||||
|
}.focusable(),
|
||||||
|
)
|
||||||
|
|
@ -133,11 +133,12 @@ fun ItemGrid(
|
||||||
showJumpButtons = false,
|
showJumpButtons = false,
|
||||||
showLetterButtons = false,
|
showLetterButtons = false,
|
||||||
spacing = 24.dp,
|
spacing = 24.dp,
|
||||||
cardContent = @Composable { item, onClick, onLongClick, mod ->
|
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||||
GridCard(
|
GridCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
fillWidth = widthPx,
|
||||||
modifier = mod,
|
modifier = mod,
|
||||||
imageAspectRatio = AspectRatios.WIDE, // TODO
|
imageAspectRatio = AspectRatios.WIDE, // TODO
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ExpandablePlayButtons(
|
fun ExpandablePlayButtons(
|
||||||
|
title: String,
|
||||||
resumePosition: Duration,
|
resumePosition: Duration,
|
||||||
watched: Boolean,
|
watched: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
|
|
@ -79,7 +80,7 @@ fun ExpandablePlayButtons(
|
||||||
favoriteOnClick: () -> Unit,
|
favoriteOnClick: () -> Unit,
|
||||||
moreOnClick: () -> Unit,
|
moreOnClick: () -> Unit,
|
||||||
trailerOnClick: (Trailer) -> Unit,
|
trailerOnClick: (Trailer) -> Unit,
|
||||||
deleteOnClick: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
buttonOnFocusChanged: (FocusState) -> Unit,
|
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -163,7 +164,8 @@ fun ExpandablePlayButtons(
|
||||||
if (canDelete) {
|
if (canDelete) {
|
||||||
item("delete") {
|
item("delete") {
|
||||||
DeleteButton(
|
DeleteButton(
|
||||||
onClick = deleteOnClick,
|
title = title,
|
||||||
|
onConfirmDelete = onConfirmDelete,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.onFocusChanged(buttonOnFocusChanged),
|
.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
|
@ -381,10 +383,12 @@ fun TrailerButton(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeleteButton(
|
fun DeleteButton(
|
||||||
onClick: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
|
title: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
) {
|
) {
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
val iconTint by
|
val iconTint by
|
||||||
animateColorAsState(
|
animateColorAsState(
|
||||||
|
|
@ -415,10 +419,20 @@ fun DeleteButton(
|
||||||
.size(24.dp),
|
.size(24.dp),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onClick.invoke() },
|
onClick = { showDeleteDialog = true },
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
if (showDeleteDialog) {
|
||||||
|
ConfirmDeleteDialog(
|
||||||
|
itemTitle = title,
|
||||||
|
onCancel = { showDeleteDialog = false },
|
||||||
|
onConfirm = {
|
||||||
|
onConfirmDelete.invoke()
|
||||||
|
showDeleteDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewTvSpec
|
@PreviewTvSpec
|
||||||
|
|
@ -426,6 +440,7 @@ fun DeleteButton(
|
||||||
private fun ExpandablePlayButtonsPreview() {
|
private fun ExpandablePlayButtonsPreview() {
|
||||||
WholphinTheme(true) {
|
WholphinTheme(true) {
|
||||||
ExpandablePlayButtons(
|
ExpandablePlayButtons(
|
||||||
|
title = "Movie",
|
||||||
resumePosition = 10.seconds,
|
resumePosition = 10.seconds,
|
||||||
watched = false,
|
watched = false,
|
||||||
favorite = false,
|
favorite = false,
|
||||||
|
|
@ -437,7 +452,7 @@ private fun ExpandablePlayButtonsPreview() {
|
||||||
trailers = listOf(),
|
trailers = listOf(),
|
||||||
trailerOnClick = {},
|
trailerOnClick = {},
|
||||||
canDelete = true,
|
canDelete = true,
|
||||||
deleteOnClick = {},
|
onConfirmDelete = {},
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -482,7 +497,8 @@ private fun ViewOptionsPreview() {
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
DeleteButton(
|
DeleteButton(
|
||||||
onClick = {},
|
onConfirmDelete = {},
|
||||||
|
title = "Movie",
|
||||||
)
|
)
|
||||||
SortByButton(
|
SortByButton(
|
||||||
sortOptions = listOf(),
|
sortOptions = listOf(),
|
||||||
|
|
@ -491,7 +507,8 @@ private fun ViewOptionsPreview() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DeleteButton(
|
DeleteButton(
|
||||||
onClick = {},
|
onConfirmDelete = {},
|
||||||
|
title = "Movie",
|
||||||
interactionSource = source,
|
interactionSource = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
|
@ -18,6 +17,7 @@ import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -31,11 +31,11 @@ import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.deleteItem
|
import com.github.damontecres.wholphin.services.deleteItem
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
|
@ -60,6 +60,7 @@ import java.util.UUID
|
||||||
abstract class RecommendedViewModel(
|
abstract class RecommendedViewModel(
|
||||||
@param:ApplicationContext val context: Context,
|
@param:ApplicationContext val context: Context,
|
||||||
val api: ApiClient,
|
val api: ApiClient,
|
||||||
|
val serverRepository: ServerRepository,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val favoriteWatchManager: FavoriteWatchManager,
|
val favoriteWatchManager: FavoriteWatchManager,
|
||||||
val mediaReportService: MediaReportService,
|
val mediaReportService: MediaReportService,
|
||||||
|
|
@ -167,10 +168,9 @@ fun RecommendedContent(
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
onFocusPosition: ((RowColumn) -> Unit)? = null,
|
onFocusPosition: ((RowColumn) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
|
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
|
||||||
OneTimeLaunchedEffect {
|
OneTimeLaunchedEffect {
|
||||||
|
|
@ -192,6 +192,35 @@ fun RecommendedContent(
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
var position by rememberPosition()
|
var position by rememberPosition()
|
||||||
|
val contextActions =
|
||||||
|
remember {
|
||||||
|
ContextMenuActions(
|
||||||
|
navigateTo = viewModel.navigationManager::navigateTo,
|
||||||
|
onClickWatch = { itemId, watched ->
|
||||||
|
viewModel.setWatched(position, itemId, watched)
|
||||||
|
},
|
||||||
|
onClickFavorite = { itemId, favorite ->
|
||||||
|
viewModel.setFavorite(position, itemId, favorite)
|
||||||
|
},
|
||||||
|
onClickAddPlaylist = { itemId ->
|
||||||
|
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
|
showPlaylistDialog.makePresent(itemId)
|
||||||
|
},
|
||||||
|
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||||
|
onDeleteItem = { viewModel.deleteItem(position, it) },
|
||||||
|
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||||
|
onChooseVersion = { _, _ ->
|
||||||
|
// Not supported on this page
|
||||||
|
},
|
||||||
|
onChooseTracks = { result ->
|
||||||
|
// Not supported on this page
|
||||||
|
},
|
||||||
|
onClearChosenStreams = {
|
||||||
|
// Not supported on this page
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
HomePageContent(
|
HomePageContent(
|
||||||
homeRows = rows,
|
homeRows = rows,
|
||||||
position = position,
|
position = position,
|
||||||
|
|
@ -199,7 +228,18 @@ fun RecommendedContent(
|
||||||
viewModel.navigationManager.navigateTo(item.destination())
|
viewModel.navigationManager.navigateTo(item.destination())
|
||||||
},
|
},
|
||||||
onLongClickItem = { position, item ->
|
onLongClickItem = { position, item ->
|
||||||
moreDialog.makePresent(RowColumnItem(position, item))
|
showContextMenu =
|
||||||
|
ContextMenu.ForBaseItem(
|
||||||
|
fromLongClick = true,
|
||||||
|
item = item,
|
||||||
|
chosenStreams = null,
|
||||||
|
showGoTo = true,
|
||||||
|
showStreamChoices = false,
|
||||||
|
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||||
|
canRemoveContinueWatching = false,
|
||||||
|
canRemoveNextUp = false,
|
||||||
|
actions = contextActions,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onClickPlay = { _, item ->
|
onClickPlay = { _, item ->
|
||||||
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||||
|
|
@ -226,42 +266,22 @@ fun RecommendedContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
moreDialog.compose { (position, item) ->
|
overviewDialog?.let { info ->
|
||||||
DialogPopup(
|
ItemDetailsDialog(
|
||||||
showDialog = true,
|
info = info,
|
||||||
title = item.title ?: "",
|
showFilePath =
|
||||||
dialogItems =
|
viewModel.serverRepository.currentUserDto.value
|
||||||
buildMoreDialogItemsForHome(
|
?.policy
|
||||||
context = context,
|
?.isAdministrator == true,
|
||||||
item = item,
|
onDismissRequest = { overviewDialog = null },
|
||||||
seriesId = null,
|
)
|
||||||
playbackPosition = item.playbackPosition,
|
}
|
||||||
watched = item.played,
|
showContextMenu?.let { contextMenu ->
|
||||||
favorite = item.favorite,
|
ContextMenuDialog(
|
||||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
onDismissRequest = { showContextMenu = null },
|
||||||
actions =
|
getMediaSource = null,
|
||||||
MoreDialogActions(
|
contextMenu = contextMenu,
|
||||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
preferredSubtitleLanguage = null,
|
||||||
onClickWatch = { itemId, watched ->
|
|
||||||
viewModel.setWatched(position, itemId, watched)
|
|
||||||
},
|
|
||||||
onClickFavorite = { itemId, watched ->
|
|
||||||
viewModel.setFavorite(position, itemId, watched)
|
|
||||||
},
|
|
||||||
onClickAddPlaylist = {
|
|
||||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
|
||||||
showPlaylistDialog.makePresent(it)
|
|
||||||
},
|
|
||||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
|
||||||
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
|
|
||||||
onClickAddToQueue = {
|
|
||||||
viewModel.addToQueue(it, 0)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onDismissRequest = { moreDialog.makeAbsent() },
|
|
||||||
dismissOnClick = true,
|
|
||||||
waitToLoad = true,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showPlaylistDialog.compose { itemId ->
|
showPlaylistDialog.compose { itemId ->
|
||||||
|
|
@ -281,16 +301,6 @@ fun RecommendedContent(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showDeleteDialog?.let { (position, item) ->
|
|
||||||
ConfirmDeleteDialog(
|
|
||||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
|
||||||
onCancel = { showDeleteDialog = null },
|
|
||||||
onConfirm = {
|
|
||||||
viewModel.deleteItem(position, item)
|
|
||||||
showDeleteDialog = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class RowColumnItem(
|
data class RowColumnItem(
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ class RecommendedMovieViewModel
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
musicService: MusicService,
|
musicService: MusicService,
|
||||||
private val serverRepository: ServerRepository,
|
serverRepository: ServerRepository,
|
||||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||||
private val suggestionService: SuggestionService,
|
private val suggestionService: SuggestionService,
|
||||||
@Assisted val parentId: UUID,
|
@Assisted val parentId: UUID,
|
||||||
|
|
@ -70,6 +70,7 @@ class RecommendedMovieViewModel
|
||||||
) : RecommendedViewModel(
|
) : RecommendedViewModel(
|
||||||
context,
|
context,
|
||||||
api,
|
api,
|
||||||
|
serverRepository,
|
||||||
navigationManager,
|
navigationManager,
|
||||||
favoriteWatchManager,
|
favoriteWatchManager,
|
||||||
mediaReportService,
|
mediaReportService,
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ class RecommendedMusicViewModel
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
musicService: MusicService,
|
musicService: MusicService,
|
||||||
private val serverRepository: ServerRepository,
|
serverRepository: ServerRepository,
|
||||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||||
private val suggestionService: SuggestionService,
|
private val suggestionService: SuggestionService,
|
||||||
@Assisted val parentId: UUID,
|
@Assisted val parentId: UUID,
|
||||||
|
|
@ -69,6 +69,7 @@ class RecommendedMusicViewModel
|
||||||
) : RecommendedViewModel(
|
) : RecommendedViewModel(
|
||||||
context,
|
context,
|
||||||
api,
|
api,
|
||||||
|
serverRepository,
|
||||||
navigationManager,
|
navigationManager,
|
||||||
favoriteWatchManager,
|
favoriteWatchManager,
|
||||||
mediaReportService,
|
mediaReportService,
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class RecommendedTvShowViewModel
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
musicService: MusicService,
|
musicService: MusicService,
|
||||||
private val serverRepository: ServerRepository,
|
serverRepository: ServerRepository,
|
||||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||||
private val lastestNextUpService: LatestNextUpService,
|
private val lastestNextUpService: LatestNextUpService,
|
||||||
private val suggestionService: SuggestionService,
|
private val suggestionService: SuggestionService,
|
||||||
|
|
@ -74,6 +74,7 @@ class RecommendedTvShowViewModel
|
||||||
) : RecommendedViewModel(
|
) : RecommendedViewModel(
|
||||||
context,
|
context,
|
||||||
api,
|
api,
|
||||||
|
serverRepository,
|
||||||
navigationManager,
|
navigationManager,
|
||||||
favoriteWatchManager,
|
favoriteWatchManager,
|
||||||
mediaReportService,
|
mediaReportService,
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ fun StudioCardGrid(
|
||||||
},
|
},
|
||||||
columns = columns,
|
columns = columns,
|
||||||
spacing = spacing,
|
spacing = spacing,
|
||||||
cardContent = { item: Studio?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||||
StudioCard(
|
StudioCard(
|
||||||
studio = item,
|
studio = item,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -34,6 +35,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -51,7 +53,8 @@ import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.onLayoutRectChanged
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
@ -62,10 +65,8 @@ import androidx.tv.material3.LocalContentColor
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.playback.isBackwardButton
|
import com.github.damontecres.wholphin.ui.playback.isBackwardButton
|
||||||
import com.github.damontecres.wholphin.ui.playback.isForwardButton
|
import com.github.damontecres.wholphin.ui.playback.isForwardButton
|
||||||
|
|
@ -76,6 +77,7 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
private const val DEBUG = false
|
private const val DEBUG = false
|
||||||
|
|
||||||
|
|
@ -85,6 +87,15 @@ interface CardGridItem {
|
||||||
val sortName: String
|
val sortName: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class GridItemDetails<T : CardGridItem>(
|
||||||
|
val item: T?,
|
||||||
|
val index: Int,
|
||||||
|
val onClick: () -> Unit,
|
||||||
|
val onLongClick: () -> Unit,
|
||||||
|
val widthPx: Int,
|
||||||
|
val mod: Modifier,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a vertical grid of [CardGridItem]s
|
* Shows a vertical grid of [CardGridItem]s
|
||||||
*/
|
*/
|
||||||
|
|
@ -102,31 +113,21 @@ fun <T : CardGridItem> CardGrid(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialPosition: Int = 0,
|
initialPosition: Int = 0,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
cardContent: @Composable (
|
cardContent: @Composable (GridItemDetails<T>) -> Unit,
|
||||||
item: T?,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onLongClick: () -> Unit,
|
|
||||||
mod: Modifier,
|
|
||||||
) -> Unit = { item, onClick, onLongClick, mod ->
|
|
||||||
GridCard(
|
|
||||||
item = item as BaseItem?,
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
imageContentScale = ContentScale.FillBounds,
|
|
||||||
modifier = mod,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
columns: Int = 6,
|
columns: Int = 6,
|
||||||
spacing: Dp = 16.dp,
|
spacing: Dp = 16.dp,
|
||||||
bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
|
bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
|
||||||
) {
|
) {
|
||||||
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
val startPosition =
|
||||||
|
remember(initialPosition, pager.size) {
|
||||||
|
initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
||||||
|
}
|
||||||
|
|
||||||
val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
|
|
||||||
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
|
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
|
||||||
|
val currentFocusedIndex by rememberUpdatedState(focusedIndex)
|
||||||
val gridState =
|
val gridState =
|
||||||
rememberLazyGridState(
|
rememberLazyGridState(
|
||||||
cacheWindow = fractionCacheWindow,
|
cacheWindow = LazyLayoutCacheWindow(aheadFraction = 2f, behindFraction = 0.5f),
|
||||||
initialFirstVisibleItemIndex = focusedIndex,
|
initialFirstVisibleItemIndex = focusedIndex,
|
||||||
)
|
)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
@ -135,13 +136,16 @@ fun <T : CardGridItem> CardGrid(
|
||||||
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
|
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
|
||||||
|
|
||||||
var alphabetFocus by remember { mutableStateOf(false) }
|
var alphabetFocus by remember { mutableStateOf(false) }
|
||||||
val focusOn = { index: Int ->
|
val focusOn =
|
||||||
if (DEBUG) Timber.v("focusOn: focusedIndex=$focusedIndex, index=$index")
|
remember {
|
||||||
if (index != focusedIndex) {
|
{ index: Int ->
|
||||||
previouslyFocusedIndex = focusedIndex
|
if (DEBUG) Timber.v("focusOn: focusedIndex=$currentFocusedIndex, index=$index")
|
||||||
|
if (index != currentFocusedIndex) {
|
||||||
|
previouslyFocusedIndex = focusedIndex
|
||||||
|
}
|
||||||
|
focusedIndex = index
|
||||||
|
}
|
||||||
}
|
}
|
||||||
focusedIndex = index
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for a recomposition to focus
|
// Wait for a recomposition to focus
|
||||||
val alphabetFocusRequester = remember { FocusRequester() }
|
val alphabetFocusRequester = remember { FocusRequester() }
|
||||||
|
|
@ -180,27 +184,33 @@ fun <T : CardGridItem> CardGrid(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val jump = { jump: Int ->
|
val jump =
|
||||||
scope.launch(ExceptionHandler()) {
|
remember {
|
||||||
val newPosition =
|
{ jump: Int ->
|
||||||
(gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size)
|
scope.launch(ExceptionHandler()) {
|
||||||
if (DEBUG) Timber.d("newPosition=$newPosition")
|
val newPosition =
|
||||||
focusOn(newPosition)
|
(gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size)
|
||||||
gridState.scrollToItem(newPosition, 0)
|
if (DEBUG) Timber.d("newPosition=$newPosition")
|
||||||
}
|
focusOn(newPosition)
|
||||||
}
|
gridState.scrollToItem(newPosition, 0)
|
||||||
val jumpToTop = {
|
}
|
||||||
scope.launch(ExceptionHandler()) {
|
}
|
||||||
if (focusedIndex < (columns * 6)) {
|
}
|
||||||
// If close, animate the scroll
|
val jumpToTop =
|
||||||
gridState.animateScrollToItem(0, 0)
|
remember {
|
||||||
} else {
|
{
|
||||||
gridState.scrollToItem(0, 0)
|
scope.launch(ExceptionHandler()) {
|
||||||
|
if (currentFocusedIndex < (columns * 6)) {
|
||||||
|
// If close, animate the scroll
|
||||||
|
gridState.animateScrollToItem(0, 0)
|
||||||
|
} else {
|
||||||
|
gridState.scrollToItem(0, 0)
|
||||||
|
}
|
||||||
|
focusOn(0)
|
||||||
|
zeroFocus.tryRequestFocus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
focusOn(0)
|
|
||||||
zeroFocus.tryRequestFocus()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val jumpToLetter: (Char) -> Unit =
|
val jumpToLetter: (Char) -> Unit =
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -298,6 +308,9 @@ fun <T : CardGridItem> CardGrid(
|
||||||
modifier = Modifier.align(Alignment.CenterVertically),
|
modifier = Modifier.align(Alignment.CenterVertically),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val density = LocalDensity.current
|
||||||
|
var cardWidthPx by rememberSaveable { mutableIntStateOf(0) }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
|
|
@ -314,57 +327,69 @@ fun <T : CardGridItem> CardGrid(
|
||||||
.focusGroup()
|
.focusGroup()
|
||||||
.focusRestorer(firstFocus)
|
.focusRestorer(firstFocus)
|
||||||
.focusProperties {
|
.focusProperties {
|
||||||
onExit = {
|
|
||||||
// Leaving the grid, so "forget" the position
|
|
||||||
// focusedIndex = -1
|
|
||||||
}
|
|
||||||
onEnter = {
|
onEnter = {
|
||||||
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
|
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
|
||||||
focusedIndex = startPosition
|
focusedIndex = startPosition
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}.onLayoutRectChanged(0, 0) {
|
||||||
|
val width = it.width
|
||||||
|
val spacingPx = with(density) { spacing.toPx() }
|
||||||
|
val cardWidth =
|
||||||
|
ceil((width - (spacingPx * (columns - 1))) / columns)
|
||||||
|
cardWidthPx = cardWidth.toInt()
|
||||||
|
Timber.v("cardWidthPx=%s", cardWidthPx)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
items(pager.size) { index ->
|
items(pager.size) { index ->
|
||||||
val mod =
|
|
||||||
if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
|
|
||||||
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
|
|
||||||
Modifier
|
|
||||||
.focusRequester(firstFocus)
|
|
||||||
.focusRequester(gridFocusRequester)
|
|
||||||
.focusRequester(alphabetFocusRequester)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
val item = pager[index]
|
val item = pager[index]
|
||||||
cardContent(
|
val details =
|
||||||
item,
|
remember(index, item) {
|
||||||
{
|
val mod =
|
||||||
if (item != null) {
|
if ((index == currentFocusedIndex) or (currentFocusedIndex < 0 && index == 0)) {
|
||||||
focusedIndex = index
|
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
|
||||||
onClickItem.invoke(index, item)
|
Modifier
|
||||||
}
|
.focusRequester(firstFocus)
|
||||||
},
|
.focusRequester(gridFocusRequester)
|
||||||
{ if (item != null) onLongClickItem.invoke(index, item) },
|
.focusRequester(alphabetFocusRequester)
|
||||||
mod
|
} else {
|
||||||
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
|
Modifier
|
||||||
.onFocusChanged { focusState ->
|
|
||||||
if (DEBUG) {
|
|
||||||
Timber.v(
|
|
||||||
"$index isFocused=${focusState.isFocused}",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (focusState.isFocused) {
|
GridItemDetails(
|
||||||
// Focused, so set that up
|
item = item,
|
||||||
focusOn(index)
|
index = index,
|
||||||
positionCallback?.invoke(columns, index)
|
onClick = {
|
||||||
} else if (focusedIndex == index) {
|
if (item != null) {
|
||||||
// savedFocusedIndex = index
|
focusedIndex = index
|
||||||
// // Was focused on this, so mark unfocused
|
onClickItem.invoke(index, item)
|
||||||
// focusedIndex = -1
|
}
|
||||||
}
|
},
|
||||||
},
|
onLongClick = {
|
||||||
)
|
if (item != null) {
|
||||||
|
onLongClickItem.invoke(index, item)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
widthPx = cardWidthPx,
|
||||||
|
mod =
|
||||||
|
mod
|
||||||
|
.ifElse(
|
||||||
|
index == 0,
|
||||||
|
Modifier.focusRequester(zeroFocus),
|
||||||
|
).onFocusChanged { focusState ->
|
||||||
|
if (DEBUG) {
|
||||||
|
Timber.v(
|
||||||
|
"$index isFocused=${focusState.isFocused}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (focusState.isFocused) {
|
||||||
|
// Focused, so set that up
|
||||||
|
focusOn(index)
|
||||||
|
positionCallback?.invoke(columns, index)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
cardContent.invoke(details)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (pager.isEmpty()) {
|
if (pager.isEmpty()) {
|
||||||
|
|
@ -429,11 +454,21 @@ fun <T : CardGridItem> CardGrid(
|
||||||
// Add end padding to push away from edge
|
// Add end padding to push away from edge
|
||||||
letterClicked = jumpToLetter,
|
letterClicked = jumpToLetter,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
// Spacer ensures card sizes do not change if the alphabet buttons are not shown
|
||||||
|
Spacer(
|
||||||
|
Modifier
|
||||||
|
.padding(start = 16.dp)
|
||||||
|
.width(letterHorizontalPadding * 2 + letterButtonSize),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val letterHorizontalPadding = 2.dp
|
||||||
|
private val letterButtonSize = 14.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun JumpButtons(
|
fun JumpButtons(
|
||||||
jump1: Int,
|
jump1: Int,
|
||||||
|
|
@ -500,7 +535,7 @@ fun AlphabetButtons(
|
||||||
var alphabetPickerFocused by remember { mutableStateOf(false) }
|
var alphabetPickerFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = 2.dp),
|
contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = letterHorizontalPadding),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.1.dp),
|
verticalArrangement = Arrangement.spacedBy(1.1.dp),
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -534,14 +569,14 @@ fun AlphabetButtons(
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.size(14.dp)
|
.size(letterButtonSize)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.alpha(itemAlpha),
|
.alpha(itemAlpha),
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.size(14.dp)
|
.size(letterButtonSize)
|
||||||
.focusRequester(focusRequesters[index]),
|
.focusRequester(focusRequesters[index]),
|
||||||
contentPadding = PaddingValues(0.dp), // No padding to maximize text space
|
contentPadding = PaddingValues(0.dp), // No padding to maximize text space
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,18 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.MusicService
|
import com.github.damontecres.wholphin.services.MusicService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
|
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
|
||||||
|
import com.github.damontecres.wholphin.ui.components.GridClickActions
|
||||||
import com.github.damontecres.wholphin.ui.components.RecommendedMusic
|
import com.github.damontecres.wholphin.ui.components.RecommendedMusic
|
||||||
import com.github.damontecres.wholphin.ui.components.TabRow
|
import com.github.damontecres.wholphin.ui.components.TabRow
|
||||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
|
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
|
||||||
|
|
@ -40,17 +44,36 @@ import com.github.damontecres.wholphin.ui.data.SongSortOptions
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.logTab
|
import com.github.damontecres.wholphin.ui.logTab
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import javax.inject.Inject
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel(assistedFactory = CollectionFolderMusicViewModel.Factory::class)
|
||||||
class CollectionFolderMusicViewModel
|
class CollectionFolderMusicViewModel
|
||||||
@Inject
|
@AssistedInject
|
||||||
constructor(
|
constructor(
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
private val musicService: MusicService,
|
private val musicService: MusicService,
|
||||||
) : ViewModel() {
|
private val navigationManager: NavigationManager,
|
||||||
|
val backdropService: BackdropService,
|
||||||
|
private val rememberTabManager: RememberTabManager,
|
||||||
|
@Assisted private val itemId: UUID,
|
||||||
|
) : ViewModel(),
|
||||||
|
RememberTabManager by rememberTabManager {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(itemId: UUID): CollectionFolderMusicViewModel
|
||||||
|
}
|
||||||
|
|
||||||
fun play(item: BaseItem) {
|
fun play(item: BaseItem) {
|
||||||
if (item.type == BaseItemKind.AUDIO) {
|
if (item.type == BaseItemKind.AUDIO) {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
|
|
@ -58,6 +81,44 @@ class CollectionFolderMusicViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onClick(
|
||||||
|
index: Int,
|
||||||
|
item: BaseItem,
|
||||||
|
) {
|
||||||
|
if (item.type == BaseItemKind.AUDIO) {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
musicService.setQueue(listOf(item), false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigationManager.navigateTo(item.destination())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onClickPlayAll(shuffle: Boolean) {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
userId = serverRepository.currentUser.value?.id,
|
||||||
|
parentId = itemId,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||||
|
recursive = true,
|
||||||
|
)
|
||||||
|
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
|
||||||
|
musicService.setQueue(pager, 0, shuffle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onClickPlayRemoteButton(
|
||||||
|
index: Int,
|
||||||
|
item: BaseItem,
|
||||||
|
) {
|
||||||
|
if (item.type == BaseItemKind.AUDIO) {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
musicService.setQueue(listOf(item), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -65,11 +126,13 @@ fun CollectionFolderMusic(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
destination: Destination.MediaItem,
|
destination: Destination.MediaItem,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: CollectionFolderMusicViewModel = hiltViewModel(),
|
viewModel: CollectionFolderMusicViewModel =
|
||||||
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
|
hiltViewModel<CollectionFolderMusicViewModel, CollectionFolderMusicViewModel.Factory>(
|
||||||
|
creationCallback = { it.create(destination.itemId) },
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
val rememberedTabIndex =
|
val rememberedTabIndex =
|
||||||
remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) }
|
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
|
||||||
|
|
||||||
val tabs =
|
val tabs =
|
||||||
listOf(
|
listOf(
|
||||||
|
|
@ -88,12 +151,22 @@ fun CollectionFolderMusic(
|
||||||
|
|
||||||
LaunchedEffect(selectedTabIndex) {
|
LaunchedEffect(selectedTabIndex) {
|
||||||
logTab("music", selectedTabIndex)
|
logTab("music", selectedTabIndex)
|
||||||
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
||||||
preferencesViewModel.backdropService.clearBackdrop()
|
viewModel.backdropService.clearBackdrop()
|
||||||
}
|
}
|
||||||
|
|
||||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
||||||
|
val actions =
|
||||||
|
remember {
|
||||||
|
GridClickActions(
|
||||||
|
onClickItem = viewModel::onClick,
|
||||||
|
onLongClickItem = null,
|
||||||
|
onClickPlayAll = viewModel::onClickPlayAll,
|
||||||
|
onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -133,9 +206,7 @@ fun CollectionFolderMusic(
|
||||||
1 -> {
|
1 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
onClickItem = { _, item ->
|
actions = actions,
|
||||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
|
||||||
},
|
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
viewModelKey = "${destination.itemId}_albums",
|
viewModelKey = "${destination.itemId}_albums",
|
||||||
initialFilter =
|
initialFilter =
|
||||||
|
|
@ -165,9 +236,7 @@ fun CollectionFolderMusic(
|
||||||
2 -> {
|
2 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
onClickItem = { _, item ->
|
actions = actions,
|
||||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
|
||||||
},
|
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
viewModelKey = "${destination.itemId}_artists",
|
viewModelKey = "${destination.itemId}_artists",
|
||||||
initialFilter =
|
initialFilter =
|
||||||
|
|
@ -209,9 +278,7 @@ fun CollectionFolderMusic(
|
||||||
4 -> {
|
4 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
onClickItem = { _, item ->
|
actions = actions,
|
||||||
viewModel.play(item)
|
|
||||||
},
|
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
viewModelKey = "${destination.itemId}_songs",
|
viewModelKey = "${destination.itemId}_songs",
|
||||||
initialFilter =
|
initialFilter =
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
|
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.components.GridClickActions
|
||||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
|
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
|
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
|
||||||
|
|
@ -51,22 +52,32 @@ fun CollectionFolderPhotoAlbum(
|
||||||
var showHeader by remember { mutableStateOf(true) }
|
var showHeader by remember { mutableStateOf(true) }
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
onClickItem = { index, item ->
|
actions =
|
||||||
val destination =
|
remember {
|
||||||
if (item.type == BaseItemKind.PHOTO) {
|
GridClickActions(
|
||||||
Destination.Slideshow(
|
onClickItem = { index, item ->
|
||||||
parentId = itemId,
|
val destination =
|
||||||
index = index,
|
if (item.type == BaseItemKind.PHOTO) {
|
||||||
filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()),
|
Destination.Slideshow(
|
||||||
sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT,
|
parentId = itemId,
|
||||||
recursive = recursive,
|
index = index,
|
||||||
startSlideshow = false,
|
filter =
|
||||||
)
|
CollectionFolderFilter(
|
||||||
} else {
|
filter = viewModel.filter.value ?: GetItemsFilter(),
|
||||||
item.destination(index)
|
),
|
||||||
}
|
sortAndDirection =
|
||||||
viewModel.navigateTo(destination)
|
viewModel.sortAndDirection.value
|
||||||
},
|
?: SortAndDirection.DEFAULT,
|
||||||
|
recursive = recursive,
|
||||||
|
startSlideshow = false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
item.destination(index)
|
||||||
|
}
|
||||||
|
viewModel.navigateTo(destination)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
itemId = itemId.toServerString(),
|
itemId = itemId.toServerString(),
|
||||||
initialFilter = filter,
|
initialFilter = filter,
|
||||||
showTitle = showHeader,
|
showTitle = showHeader,
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,16 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.ArrowForward
|
import androidx.compose.material.icons.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Info
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.util.supportedPlayableTypes
|
import com.github.damontecres.wholphin.util.supportedPlayableTypes
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
@ -36,234 +31,6 @@ data class MoreDialogActions(
|
||||||
val onClickAddToQueue: (BaseItem) -> Unit = {},
|
val onClickAddToQueue: (BaseItem) -> Unit = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class ClearChosenStreams {
|
|
||||||
NONE,
|
|
||||||
ITEM_AND_SERIES,
|
|
||||||
SERIES,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the [DialogItem]s when clicking "More"
|
|
||||||
*
|
|
||||||
* If there are multiple versions, adds an option to pick one
|
|
||||||
*
|
|
||||||
* If there is more than one (ie two or more) audio track, adds an option to pick one
|
|
||||||
*
|
|
||||||
* If there are any (ie one or more) subtitle tracks, adds an option to disable or pick one
|
|
||||||
*
|
|
||||||
* @param item the media item to build for, typically an Episode or Movie
|
|
||||||
* @param seriesId the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option
|
|
||||||
* @param sourceId the item's media source UUID
|
|
||||||
* @param onChooseVersion callback to pick a version of the item
|
|
||||||
* @param onChooseTracks callback to pick a track for the given type of the item
|
|
||||||
* @param onShowOverview callback to show overview dialog with media information
|
|
||||||
*/
|
|
||||||
fun buildMoreDialogItems(
|
|
||||||
context: Context,
|
|
||||||
item: BaseItem,
|
|
||||||
seriesId: UUID?,
|
|
||||||
sourceId: UUID?,
|
|
||||||
watched: Boolean,
|
|
||||||
favorite: Boolean,
|
|
||||||
canClearChosenStreams: Boolean,
|
|
||||||
canDelete: Boolean,
|
|
||||||
actions: MoreDialogActions,
|
|
||||||
onChooseVersion: () -> Unit,
|
|
||||||
onChooseTracks: (MediaStreamType) -> Unit,
|
|
||||||
onShowOverview: () -> Unit,
|
|
||||||
onClearChosenStreams: () -> Unit,
|
|
||||||
): List<DialogItem> =
|
|
||||||
buildList {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.play),
|
|
||||||
Icons.Default.PlayArrow,
|
|
||||||
iconColor = Color.Green.copy(alpha = .8f),
|
|
||||||
) {
|
|
||||||
actions.navigateTo(
|
|
||||||
Destination.Playback(
|
|
||||||
item.id,
|
|
||||||
item.resumeMs ?: 0L,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
item.data.mediaSources?.letNotEmpty { sources ->
|
|
||||||
val source =
|
|
||||||
sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } }
|
|
||||||
?: sources.firstOrNull()
|
|
||||||
source?.mediaStreams?.letNotEmpty { streams ->
|
|
||||||
val audioCount = streams.count { it.type == MediaStreamType.AUDIO }
|
|
||||||
val subtitleCount = streams.count { it.type == MediaStreamType.SUBTITLE }
|
|
||||||
if (audioCount > 1) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(
|
|
||||||
R.string.choose_stream,
|
|
||||||
context.getString(R.string.audio),
|
|
||||||
),
|
|
||||||
R.string.fa_volume_low,
|
|
||||||
) {
|
|
||||||
onChooseTracks.invoke(MediaStreamType.AUDIO)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (subtitleCount > 0) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(
|
|
||||||
R.string.choose_stream,
|
|
||||||
context.getString(R.string.subtitles),
|
|
||||||
),
|
|
||||||
R.string.fa_closed_captioning,
|
|
||||||
) {
|
|
||||||
onChooseTracks.invoke(MediaStreamType.SUBTITLE)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sources.size > 1) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(
|
|
||||||
R.string.choose_stream,
|
|
||||||
context.getString(R.string.version),
|
|
||||||
),
|
|
||||||
R.string.fa_file_video,
|
|
||||||
) {
|
|
||||||
onChooseVersion.invoke()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
text = R.string.add_to_playlist,
|
|
||||||
iconStringRes = R.string.fa_list_ul,
|
|
||||||
) {
|
|
||||||
actions.onClickAddPlaylist.invoke(item.id)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (canDelete) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.delete),
|
|
||||||
Icons.Default.Delete,
|
|
||||||
iconColor = Color.Red.copy(alpha = .8f),
|
|
||||||
) {
|
|
||||||
actions.onClickDelete.invoke(item)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
|
||||||
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
|
|
||||||
) {
|
|
||||||
actions.onClickWatch.invoke(item.id, !watched)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
|
|
||||||
iconStringRes = R.string.fa_heart,
|
|
||||||
iconColor = if (favorite) Color.Red else Color.Unspecified,
|
|
||||||
) {
|
|
||||||
actions.onClickFavorite.invoke(item.id, !favorite)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
item.data.albumId?.let { albumId ->
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.go_to_album),
|
|
||||||
R.string.fa_compact_disc,
|
|
||||||
) {
|
|
||||||
actions.navigateTo(
|
|
||||||
Destination.MediaItem(
|
|
||||||
albumId,
|
|
||||||
BaseItemKind.MUSIC_ALBUM,
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item.data.artistItems?.firstOrNull()?.id?.let { artistId ->
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.go_to_artist),
|
|
||||||
R.string.fa_user,
|
|
||||||
) {
|
|
||||||
actions.navigateTo(
|
|
||||||
Destination.MediaItem(
|
|
||||||
artistId,
|
|
||||||
BaseItemKind.MUSIC_ARTIST,
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
seriesId?.let {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.go_to_series),
|
|
||||||
Icons.AutoMirrored.Filled.ArrowForward,
|
|
||||||
) {
|
|
||||||
actions.navigateTo(
|
|
||||||
Destination.MediaItem(
|
|
||||||
seriesId,
|
|
||||||
BaseItemKind.SERIES,
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (item.data.mediaSources?.isNotEmpty() == true) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.media_information),
|
|
||||||
Icons.Default.Info,
|
|
||||||
) {
|
|
||||||
onShowOverview.invoke()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (canClearChosenStreams) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.clear_track_choices),
|
|
||||||
Icons.Default.Delete,
|
|
||||||
) {
|
|
||||||
onClearChosenStreams()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.play_with_transcoding),
|
|
||||||
Icons.Default.PlayArrow,
|
|
||||||
) {
|
|
||||||
actions.navigateTo(
|
|
||||||
Destination.Playback(
|
|
||||||
item.id,
|
|
||||||
item.resumeMs ?: 0L,
|
|
||||||
forceTranscoding = true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
text = R.string.send_media_info_log_to_server,
|
|
||||||
iconStringRes = R.string.fa_file_video,
|
|
||||||
) {
|
|
||||||
actions.onSendMediaInfo.invoke(item.id)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun buildMoreDialogItemsForHome(
|
fun buildMoreDialogItemsForHome(
|
||||||
context: Context,
|
context: Context,
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
|
|
@ -424,29 +191,3 @@ fun buildMoreDialogItemsForHome(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun buildMoreDialogItemsForPerson(
|
|
||||||
context: Context,
|
|
||||||
person: Person,
|
|
||||||
actions: MoreDialogActions,
|
|
||||||
): List<DialogItem> =
|
|
||||||
buildList {
|
|
||||||
val itemId = person.id
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.go_to),
|
|
||||||
Icons.Default.ArrowForward,
|
|
||||||
) {
|
|
||||||
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
text = if (person.favorite) R.string.remove_favorite else R.string.add_favorite,
|
|
||||||
iconStringRes = R.string.fa_heart,
|
|
||||||
iconColor = if (person.favorite) Color.Red else Color.Unspecified,
|
|
||||||
) {
|
|
||||||
actions.onClickFavorite.invoke(itemId, !person.favorite)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.GridClickActions
|
||||||
import com.github.damontecres.wholphin.ui.components.TabRow
|
import com.github.damontecres.wholphin.ui.components.TabRow
|
||||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
|
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
|
||||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
|
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
|
||||||
|
|
@ -109,13 +110,20 @@ fun FavoritesPage(
|
||||||
focusRequesters = tabFocusRequesters,
|
focusRequesters = tabFocusRequesters,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val actions =
|
||||||
|
remember {
|
||||||
|
GridClickActions(
|
||||||
|
onClickItem = { _, item -> onClickItem.invoke(item) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO playEnabled = true for movies & episodes
|
// TODO playEnabled = true for movies & episodes
|
||||||
when (selectedTabIndex) {
|
when (selectedTabIndex) {
|
||||||
// Movies
|
// Movies
|
||||||
0 -> {
|
0 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
onClickItem = { _, item -> onClickItem.invoke(item) },
|
actions = actions,
|
||||||
itemId = "${NavDrawerItem.Favorites.id}_movies",
|
itemId = "${NavDrawerItem.Favorites.id}_movies",
|
||||||
initialFilter =
|
initialFilter =
|
||||||
CollectionFolderFilter(
|
CollectionFolderFilter(
|
||||||
|
|
@ -147,7 +155,7 @@ fun FavoritesPage(
|
||||||
1 -> {
|
1 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
onClickItem = { _, item -> onClickItem.invoke(item) },
|
actions = actions,
|
||||||
itemId = "${NavDrawerItem.Favorites.id}_series",
|
itemId = "${NavDrawerItem.Favorites.id}_series",
|
||||||
initialFilter =
|
initialFilter =
|
||||||
CollectionFolderFilter(
|
CollectionFolderFilter(
|
||||||
|
|
@ -179,7 +187,7 @@ fun FavoritesPage(
|
||||||
2 -> {
|
2 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
onClickItem = { _, item -> onClickItem.invoke(item) },
|
actions = actions,
|
||||||
itemId = "${NavDrawerItem.Favorites.id}_episodes",
|
itemId = "${NavDrawerItem.Favorites.id}_episodes",
|
||||||
initialFilter =
|
initialFilter =
|
||||||
CollectionFolderFilter(
|
CollectionFolderFilter(
|
||||||
|
|
@ -212,7 +220,7 @@ fun FavoritesPage(
|
||||||
3 -> {
|
3 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
onClickItem = { _, item -> onClickItem.invoke(item) },
|
actions = actions,
|
||||||
itemId = "${NavDrawerItem.Favorites.id}_videos",
|
itemId = "${NavDrawerItem.Favorites.id}_videos",
|
||||||
initialFilter =
|
initialFilter =
|
||||||
CollectionFolderFilter(
|
CollectionFolderFilter(
|
||||||
|
|
@ -244,7 +252,7 @@ fun FavoritesPage(
|
||||||
4 -> {
|
4 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
onClickItem = { _, item -> onClickItem.invoke(item) },
|
actions = actions,
|
||||||
itemId = "${NavDrawerItem.Favorites.id}_playlists",
|
itemId = "${NavDrawerItem.Favorites.id}_playlists",
|
||||||
initialFilter =
|
initialFilter =
|
||||||
CollectionFolderFilter(
|
CollectionFolderFilter(
|
||||||
|
|
@ -276,7 +284,7 @@ fun FavoritesPage(
|
||||||
5 -> {
|
5 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
onClickItem = { _, item -> onClickItem.invoke(item) },
|
actions = actions,
|
||||||
itemId = "${NavDrawerItem.Favorites.id}_people",
|
itemId = "${NavDrawerItem.Favorites.id}_people",
|
||||||
initialFilter =
|
initialFilter =
|
||||||
CollectionFolderFilter(
|
CollectionFolderFilter(
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -72,14 +73,15 @@ import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
|
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
|
||||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||||
import com.github.damontecres.wholphin.ui.components.FilterByButton
|
import com.github.damontecres.wholphin.ui.components.FilterByButton
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.MusicContextActions
|
||||||
import com.github.damontecres.wholphin.ui.components.Optional
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.components.SortByButton
|
import com.github.damontecres.wholphin.ui.components.SortByButton
|
||||||
|
|
@ -87,10 +89,8 @@ import com.github.damontecres.wholphin.ui.components.TextButton
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
|
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.detail.music.MusicMoreDialogActions
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker
|
import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker
|
||||||
import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel
|
import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel
|
||||||
import com.github.damontecres.wholphin.ui.detail.music.buildMoreDialogForMusic
|
|
||||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||||
|
|
@ -371,7 +371,7 @@ fun PlaylistDetails(
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val musicState by viewModel.musicState.collectAsState()
|
val musicState by viewModel.musicState.collectAsState()
|
||||||
|
|
||||||
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var showConfirmTypeDialog by remember { mutableStateOf<Triple<Int, BaseItem, Boolean>?>(null) }
|
var showConfirmTypeDialog by remember { mutableStateOf<Triple<Int, BaseItem, Boolean>?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
|
@ -404,34 +404,40 @@ fun PlaylistDetails(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
val musicContextActions =
|
||||||
val musicMoreActions =
|
MusicContextActions(
|
||||||
MusicMoreDialogActions(
|
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||||
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
|
||||||
onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) },
|
onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) },
|
||||||
onClickPlayNext = { index, item -> viewModel.playNext(item) },
|
onClickPlayNext = { index, item -> viewModel.playNext(item) },
|
||||||
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, Int.MAX_VALUE) },
|
onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) },
|
||||||
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
|
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
|
||||||
onClickAddPlaylist = { itemId ->
|
onClickAddPlaylist = { itemId ->
|
||||||
addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO)
|
addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO)
|
||||||
showPlaylistDialog.makePresent(itemId)
|
showPlaylistDialog.makePresent(itemId)
|
||||||
},
|
},
|
||||||
onClickRemoveFromQueue = {},
|
onClickRemoveFromQueue = { _, _ -> },
|
||||||
onClickDelete = { showDeleteDialog = it },
|
onDeleteItem = viewModel::deleteItem,
|
||||||
)
|
|
||||||
val moreActions =
|
|
||||||
MoreDialogActions(
|
|
||||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
|
||||||
onClickWatch = { id, watched -> viewModel.setWatched(id, watched) },
|
|
||||||
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
|
|
||||||
onClickAddPlaylist = { itemId ->
|
|
||||||
addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO)
|
|
||||||
showPlaylistDialog.makePresent(itemId)
|
|
||||||
},
|
|
||||||
onSendMediaInfo = viewModel::sendMediaReport,
|
|
||||||
onClickDelete = { showDeleteDialog = it },
|
|
||||||
onClickAddToQueue = { viewModel.addToQueue(it, 0) },
|
|
||||||
)
|
)
|
||||||
|
val contextActions =
|
||||||
|
remember {
|
||||||
|
ContextMenuActions(
|
||||||
|
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||||
|
onClickWatch = { id, watched -> viewModel.setWatched(id, watched) },
|
||||||
|
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
|
||||||
|
onClickAddPlaylist = { itemId ->
|
||||||
|
addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
|
showPlaylistDialog.makePresent(itemId)
|
||||||
|
},
|
||||||
|
onSendMediaInfo = viewModel::sendMediaReport,
|
||||||
|
onDeleteItem = viewModel::deleteItem,
|
||||||
|
onClickAddToQueue = { viewModel.addToQueue(it, 0) },
|
||||||
|
onShowOverview = {},
|
||||||
|
onChooseVersion = { _, _ -> },
|
||||||
|
onChooseTracks = {},
|
||||||
|
onClearChosenStreams = {},
|
||||||
|
onClickRemoveFromNextUp = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
PlaylistDetailsContent(
|
PlaylistDetailsContent(
|
||||||
loadingState = state.loading,
|
loadingState = state.loading,
|
||||||
|
|
@ -448,43 +454,41 @@ fun PlaylistDetails(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongClickIndex = { index, item ->
|
onLongClickIndex = { index, item ->
|
||||||
longClickDialog =
|
showContextMenu =
|
||||||
DialogParams(
|
if (item.type == BaseItemKind.AUDIO) {
|
||||||
fromLongClick = true,
|
ContextMenu.ForMusic(
|
||||||
title = item.name ?: "",
|
fromLongClick = true,
|
||||||
items =
|
item = item,
|
||||||
if (item.type == BaseItemKind.AUDIO) {
|
index = index,
|
||||||
buildMoreDialogForMusic(
|
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||||
context = context,
|
canRemoveFromQueue = false,
|
||||||
actions = musicMoreActions,
|
actions = musicContextActions,
|
||||||
item = item,
|
)
|
||||||
index = index,
|
} else {
|
||||||
canRemove = false,
|
ContextMenu.ForBaseItem(
|
||||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
fromLongClick = true,
|
||||||
)
|
item = item,
|
||||||
} else {
|
chosenStreams = null,
|
||||||
buildMoreDialogItemsForHome(
|
showGoTo = true,
|
||||||
context = context,
|
showStreamChoices = false,
|
||||||
item = item,
|
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||||
seriesId = item.data.seriesId,
|
canRemoveContinueWatching = false,
|
||||||
playbackPosition = item.playbackPosition,
|
canRemoveNextUp = false,
|
||||||
watched = item.played,
|
actions = contextActions,
|
||||||
favorite = item.favorite,
|
)
|
||||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
}
|
||||||
actions = moreActions,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
filterAndSort = state.filterAndSort,
|
filterAndSort = state.filterAndSort,
|
||||||
onFilterAndSortChange = viewModel::loadItems,
|
onFilterAndSortChange = viewModel::loadItems,
|
||||||
getPossibleFilterValues = viewModel::getFilterOptionValues,
|
getPossibleFilterValues = viewModel::getFilterOptionValues,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
longClickDialog?.let { params ->
|
showContextMenu?.let { contextMenu ->
|
||||||
DialogPopup(
|
ContextMenuDialog(
|
||||||
params = params,
|
onDismissRequest = { showContextMenu = null },
|
||||||
onDismissRequest = { longClickDialog = null },
|
getMediaSource = null,
|
||||||
|
contextMenu = contextMenu,
|
||||||
|
preferredSubtitleLanguage = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showConfirmTypeDialog?.let { (index, item, shuffle) ->
|
showConfirmTypeDialog?.let { (index, item, shuffle) ->
|
||||||
|
|
@ -510,16 +514,6 @@ fun PlaylistDetails(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showDeleteDialog?.let { item ->
|
|
||||||
ConfirmDeleteDialog(
|
|
||||||
itemTitle = item.title ?: "",
|
|
||||||
onCancel = { showDeleteDialog = null },
|
|
||||||
onConfirm = {
|
|
||||||
viewModel.deleteItem(item)
|
|
||||||
showDeleteDialog = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -788,6 +782,9 @@ fun PlaylistItem(
|
||||||
isQueued: Boolean = false,
|
isQueued: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
val imageWidth = 160.dp
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } }
|
||||||
ListItem(
|
ListItem(
|
||||||
selected = false,
|
selected = false,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
@ -850,8 +847,9 @@ fun PlaylistItem(
|
||||||
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
|
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
|
||||||
watchedPercent = 0.0,
|
watchedPercent = 0.0,
|
||||||
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
|
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
|
||||||
modifier = Modifier.width(160.dp),
|
modifier = Modifier.width(imageWidth),
|
||||||
useFallbackText = false,
|
useFallbackText = false,
|
||||||
|
fillWidth = imageWidthPx,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ fun CollectionButtons(
|
||||||
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
||||||
onClickViewOptions: () -> Unit,
|
onClickViewOptions: () -> Unit,
|
||||||
favoriteOnClick: () -> Unit,
|
favoriteOnClick: () -> Unit,
|
||||||
deleteOnClick: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
canDelete: Boolean,
|
canDelete: Boolean,
|
||||||
moreOnClick: () -> Unit,
|
moreOnClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -91,9 +91,9 @@ fun CollectionButtons(
|
||||||
if (canDelete) {
|
if (canDelete) {
|
||||||
item("delete") {
|
item("delete") {
|
||||||
DeleteButton(
|
DeleteButton(
|
||||||
onClick = deleteOnClick,
|
title = state.collection?.title ?: "",
|
||||||
modifier =
|
onConfirmDelete = onConfirmDelete,
|
||||||
Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.collection
|
package com.github.damontecres.wholphin.ui.detail.collection
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.SharedTransitionLayout
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
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
|
||||||
|
|
@ -16,9 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
@ -33,7 +28,6 @@ import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -44,12 +38,12 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HiddenFocusBox
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.Optional
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
|
|
@ -57,10 +51,8 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
|
@ -85,53 +77,58 @@ fun CollectionDetails(
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
// Dialogs
|
// Dialogs
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
var showDeleteDialog by remember { mutableStateOf<Pair<RowColumn?, BaseItem>?>(null) }
|
|
||||||
var showViewOptionsDialog by remember { mutableStateOf(false) }
|
var showViewOptionsDialog by remember { mutableStateOf(false) }
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
|
|
||||||
|
fun contextActionsFor(position: RowColumn?) =
|
||||||
|
ContextMenuActions(
|
||||||
|
navigateTo = viewModel::navigateTo,
|
||||||
|
onClickWatch = { itemId, watched ->
|
||||||
|
viewModel.setWatched(itemId, watched, position)
|
||||||
|
},
|
||||||
|
onClickFavorite = { itemId, favorite ->
|
||||||
|
viewModel.setFavorite(itemId, favorite, position)
|
||||||
|
},
|
||||||
|
onClickAddPlaylist = { itemId ->
|
||||||
|
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
|
showPlaylistDialog.makePresent(itemId)
|
||||||
|
},
|
||||||
|
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||||
|
onDeleteItem = { viewModel.deleteItem(it, position) },
|
||||||
|
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||||
|
onChooseVersion = { _, _ ->
|
||||||
|
// Not supported on this page
|
||||||
|
},
|
||||||
|
onChooseTracks = { result ->
|
||||||
|
// Not supported on this page
|
||||||
|
},
|
||||||
|
onClearChosenStreams = {
|
||||||
|
// Not supported on this page
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
val onClickItem =
|
val onClickItem =
|
||||||
remember {
|
remember {
|
||||||
{ _: RowColumn, item: BaseItem -> viewModel.navigate(item.destination()) }
|
{ _: RowColumn, item: BaseItem -> viewModel.navigateTo(item.destination()) }
|
||||||
}
|
}
|
||||||
val onLongClickItem =
|
val onLongClickItem =
|
||||||
remember {
|
remember {
|
||||||
{ position: RowColumn, item: BaseItem ->
|
{ position: RowColumn, item: BaseItem ->
|
||||||
val dialogItems =
|
showContextMenu =
|
||||||
buildMoreDialogItemsForHome(
|
ContextMenu.ForBaseItem(
|
||||||
context = context,
|
|
||||||
item = item,
|
|
||||||
seriesId = item.data.seriesId,
|
|
||||||
playbackPosition = item.playbackPosition,
|
|
||||||
watched = item.played,
|
|
||||||
favorite = item.favorite,
|
|
||||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
|
||||||
actions =
|
|
||||||
MoreDialogActions(
|
|
||||||
navigateTo = viewModel::navigate,
|
|
||||||
onClickWatch = { itemId, watched ->
|
|
||||||
viewModel.setWatched(itemId, watched, position)
|
|
||||||
},
|
|
||||||
onClickFavorite = { itemId, favorite ->
|
|
||||||
viewModel.setFavorite(itemId, favorite, position)
|
|
||||||
},
|
|
||||||
onClickAddPlaylist = { itemId ->
|
|
||||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
|
||||||
showPlaylistDialog.makePresent(itemId)
|
|
||||||
},
|
|
||||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
|
||||||
onClickDelete = { item -> showDeleteDialog = Pair(position, item) },
|
|
||||||
onClickAddToQueue = { viewModel.addToQueue(it, 0) },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
moreDialog =
|
|
||||||
DialogParams(
|
|
||||||
fromLongClick = true,
|
fromLongClick = true,
|
||||||
title = item.title ?: "",
|
item = item,
|
||||||
items = dialogItems,
|
chosenStreams = null,
|
||||||
|
showGoTo = true,
|
||||||
|
showStreamChoices = false,
|
||||||
|
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||||
|
canRemoveContinueWatching = false,
|
||||||
|
canRemoveNextUp = false,
|
||||||
|
actions = contextActionsFor(position),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +141,7 @@ fun CollectionDetails(
|
||||||
{ filter: GetItemsFilter -> viewModel.changeFilter(filter) }
|
{ filter: GetItemsFilter -> viewModel.changeFilter(filter) }
|
||||||
}
|
}
|
||||||
val onClickPlay = { _: RowColumn, item: BaseItem ->
|
val onClickPlay = { _: RowColumn, item: BaseItem ->
|
||||||
viewModel.navigate(Destination.Playback(item = item))
|
viewModel.navigateTo(Destination.Playback(item = item))
|
||||||
}
|
}
|
||||||
val onClickPlayAll =
|
val onClickPlayAll =
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -158,7 +155,7 @@ fun CollectionDetails(
|
||||||
sortAndDirection = state.sortAndDirection,
|
sortAndDirection = state.sortAndDirection,
|
||||||
filter = state.itemFilter,
|
filter = state.itemFilter,
|
||||||
)
|
)
|
||||||
viewModel.navigate(dest)
|
viewModel.navigateTo(dest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val onClickViewOptions = remember { { showViewOptionsDialog = true } }
|
val onClickViewOptions = remember { { showViewOptionsDialog = true } }
|
||||||
|
|
@ -201,7 +198,7 @@ fun CollectionDetails(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
deleteOnClick =
|
onConfirmDelete =
|
||||||
remember {
|
remember {
|
||||||
{
|
{
|
||||||
state.collection?.let {
|
state.collection?.let {
|
||||||
|
|
@ -210,43 +207,30 @@ fun CollectionDetails(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
canDelete =
|
canDelete =
|
||||||
remember(state.collection) {
|
remember(
|
||||||
|
state.collection,
|
||||||
|
preferences.appPreferences.interfacePreferences.enableMediaManagement,
|
||||||
|
) {
|
||||||
state.collection?.let {
|
state.collection?.let {
|
||||||
viewModel.canDelete(it, preferences.appPreferences)
|
viewModel.canDelete(it, preferences.appPreferences)
|
||||||
} ?: false
|
} ?: false
|
||||||
},
|
},
|
||||||
moreOnClick = {
|
moreOnClick = {
|
||||||
val collection = state.collection!!
|
showContextMenu =
|
||||||
val items =
|
ContextMenu.ForBaseItem(
|
||||||
buildMoreDialogItemsForCollection(
|
|
||||||
context = context,
|
|
||||||
item = collection,
|
|
||||||
favorite = collection.favorite,
|
|
||||||
canDelete = viewModel.canDelete(collection, preferences.appPreferences),
|
|
||||||
onClickPlayAll = onClickPlayAll,
|
|
||||||
actions =
|
|
||||||
MoreDialogActions(
|
|
||||||
navigateTo = viewModel::navigate,
|
|
||||||
onClickWatch = { itemId, watched ->
|
|
||||||
viewModel.setWatched(itemId, watched, null)
|
|
||||||
},
|
|
||||||
onClickFavorite = { itemId, favorite ->
|
|
||||||
viewModel.setFavorite(itemId, favorite, null)
|
|
||||||
},
|
|
||||||
onClickAddPlaylist = { itemId ->
|
|
||||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
|
||||||
showPlaylistDialog.makePresent(itemId)
|
|
||||||
},
|
|
||||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
|
||||||
onClickDelete = { item -> showDeleteDialog = Pair(null, item) },
|
|
||||||
onClickAddToQueue = { viewModel.addToQueue(it, 0) },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
moreDialog =
|
|
||||||
DialogParams(
|
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
title = collection.title ?: "",
|
item = state.collection!!,
|
||||||
items = items,
|
chosenStreams = null,
|
||||||
|
showGoTo = false,
|
||||||
|
showStreamChoices = false,
|
||||||
|
canDelete =
|
||||||
|
viewModel.canDelete(
|
||||||
|
state.collection!!,
|
||||||
|
preferences.appPreferences,
|
||||||
|
),
|
||||||
|
canRemoveContinueWatching = false,
|
||||||
|
canRemoveNextUp = false,
|
||||||
|
actions = contextActionsFor(null),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -259,14 +243,22 @@ fun CollectionDetails(
|
||||||
onViewOptionsChange = viewModel::changeViewOptions,
|
onViewOptionsChange = viewModel::changeViewOptions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
moreDialog?.let { params ->
|
overviewDialog?.let { info ->
|
||||||
DialogPopup(
|
ItemDetailsDialog(
|
||||||
showDialog = true,
|
info = info,
|
||||||
title = params.title,
|
showFilePath =
|
||||||
dialogItems = params.items,
|
viewModel.serverRepository.currentUserDto.value
|
||||||
onDismissRequest = { moreDialog = null },
|
?.policy
|
||||||
dismissOnClick = true,
|
?.isAdministrator == true,
|
||||||
waitToLoad = params.fromLongClick,
|
onDismissRequest = { overviewDialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showContextMenu?.let { contextMenu ->
|
||||||
|
ContextMenuDialog(
|
||||||
|
onDismissRequest = { showContextMenu = null },
|
||||||
|
getMediaSource = null,
|
||||||
|
contextMenu = contextMenu,
|
||||||
|
preferredSubtitleLanguage = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showPlaylistDialog.compose { itemId ->
|
showPlaylistDialog.compose { itemId ->
|
||||||
|
|
@ -286,26 +278,6 @@ fun CollectionDetails(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showDeleteDialog?.let { (position, item) ->
|
|
||||||
ConfirmDeleteDialog(
|
|
||||||
itemTitle = item.title ?: "",
|
|
||||||
onCancel = { showDeleteDialog = null },
|
|
||||||
onConfirm = {
|
|
||||||
viewModel.deleteItem(item, position)
|
|
||||||
showDeleteDialog = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
overviewDialog?.let { info ->
|
|
||||||
ItemDetailsDialog(
|
|
||||||
info = info,
|
|
||||||
showFilePath =
|
|
||||||
viewModel.serverRepository.currentUserDto.value
|
|
||||||
?.policy
|
|
||||||
?.isAdministrator == true,
|
|
||||||
onDismissRequest = { overviewDialog = null },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -324,7 +296,7 @@ fun CollectionDetailsContent(
|
||||||
onClickViewOptions: () -> Unit,
|
onClickViewOptions: () -> Unit,
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
favoriteOnClick: () -> Unit,
|
favoriteOnClick: () -> Unit,
|
||||||
deleteOnClick: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
canDelete: Boolean,
|
canDelete: Boolean,
|
||||||
moreOnClick: () -> Unit,
|
moreOnClick: () -> Unit,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
|
|
@ -363,15 +335,9 @@ fun CollectionDetailsContent(
|
||||||
) {
|
) {
|
||||||
// This box exists so that there is something focusable above the item content
|
// This box exists so that there is something focusable above the item content
|
||||||
// allowing focus to move up to restore the collection's header
|
// allowing focus to move up to restore the collection's header
|
||||||
Box(
|
HiddenFocusBox {
|
||||||
modifier =
|
itemsContentHasFocus = false
|
||||||
Modifier
|
}
|
||||||
.fillMaxWidth()
|
|
||||||
.height(0.dp)
|
|
||||||
.onFocusChanged {
|
|
||||||
if (it.isFocused) itemsContentHasFocus = false
|
|
||||||
}.focusable(),
|
|
||||||
)
|
|
||||||
if (state.viewOptions.cardViewOptions.showDetails) {
|
if (state.viewOptions.cardViewOptions.showDetails) {
|
||||||
HomePageHeader(
|
HomePageHeader(
|
||||||
item = focusedItem,
|
item = focusedItem,
|
||||||
|
|
@ -429,7 +395,7 @@ fun CollectionDetailsContent(
|
||||||
getPossibleFilterValues = getPossibleFilterValues,
|
getPossibleFilterValues = getPossibleFilterValues,
|
||||||
onClickViewOptions = onClickViewOptions,
|
onClickViewOptions = onClickViewOptions,
|
||||||
favoriteOnClick = favoriteOnClick,
|
favoriteOnClick = favoriteOnClick,
|
||||||
deleteOnClick = deleteOnClick,
|
onConfirmDelete = onConfirmDelete,
|
||||||
canDelete = canDelete,
|
canDelete = canDelete,
|
||||||
moreOnClick = moreOnClick,
|
moreOnClick = moreOnClick,
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -491,60 +457,3 @@ fun CollectionDetailsContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun buildMoreDialogItemsForCollection(
|
|
||||||
context: Context,
|
|
||||||
item: BaseItem,
|
|
||||||
favorite: Boolean,
|
|
||||||
canDelete: Boolean,
|
|
||||||
onClickPlayAll: (shuffle: Boolean) -> Unit,
|
|
||||||
actions: MoreDialogActions,
|
|
||||||
): List<DialogItem> =
|
|
||||||
buildList {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.play),
|
|
||||||
Icons.Default.PlayArrow,
|
|
||||||
iconColor = Color.Green.copy(alpha = .8f),
|
|
||||||
) {
|
|
||||||
onClickPlayAll.invoke(false)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.shuffle),
|
|
||||||
R.string.fa_shuffle,
|
|
||||||
) {
|
|
||||||
onClickPlayAll.invoke(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
text = R.string.add_to_playlist,
|
|
||||||
iconStringRes = R.string.fa_list_ul,
|
|
||||||
) {
|
|
||||||
actions.onClickAddPlaylist.invoke(item.id)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (canDelete) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.delete),
|
|
||||||
Icons.Default.Delete,
|
|
||||||
iconColor = Color.Red.copy(alpha = .8f),
|
|
||||||
) {
|
|
||||||
actions.onClickDelete.invoke(item)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
|
|
||||||
iconStringRes = R.string.fa_heart,
|
|
||||||
iconColor = if (favorite) Color.Red else Color.Unspecified,
|
|
||||||
) {
|
|
||||||
actions.onClickFavorite.invoke(item.id, !favorite)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ fun CollectionMixedGrid(
|
||||||
positionCallback = { _, newPosition ->
|
positionCallback = { _, newPosition ->
|
||||||
onFocusPosition.invoke(RowColumn(0, newPosition))
|
onFocusPosition.invoke(RowColumn(0, newPosition))
|
||||||
},
|
},
|
||||||
cardContent = { item, onClick, onLongClick, mod ->
|
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||||
GridCard(
|
GridCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
@ -69,6 +69,7 @@ fun CollectionMixedGrid(
|
||||||
imageAspectRatio = cardViewOptions.aspectRatio.ratio,
|
imageAspectRatio = cardViewOptions.aspectRatio.ratio,
|
||||||
imageType = cardViewOptions.imageType,
|
imageType = cardViewOptions.imageType,
|
||||||
showTitle = cardViewOptions.showTitles,
|
showTitle = cardViewOptions.showTitles,
|
||||||
|
fillWidth = widthPx,
|
||||||
modifier = mod,
|
modifier = mod,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -382,7 +382,7 @@ class CollectionViewModel
|
||||||
result.totalRecordCount
|
result.totalRecordCount
|
||||||
}
|
}
|
||||||
|
|
||||||
fun navigate(destination: Destination) {
|
fun navigateTo(destination: Destination) {
|
||||||
release()
|
release()
|
||||||
navigationManager.navigateTo(destination)
|
navigationManager.navigateTo(destination)
|
||||||
}
|
}
|
||||||
|
|
@ -393,7 +393,9 @@ class CollectionViewModel
|
||||||
position: RowColumn?,
|
position: RowColumn?,
|
||||||
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||||
favoriteWatchManager.setWatched(itemId, played)
|
favoriteWatchManager.setWatched(itemId, played)
|
||||||
if (position != null) {
|
if (itemId == state.value.collection?.id) {
|
||||||
|
refreshCollection()
|
||||||
|
} else if (position != null) {
|
||||||
refreshItem(itemId, position, false)
|
refreshItem(itemId, position, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -404,11 +406,24 @@ class CollectionViewModel
|
||||||
position: RowColumn?,
|
position: RowColumn?,
|
||||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
favoriteWatchManager.setFavorite(itemId, favorite)
|
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||||
if (position != null) {
|
if (itemId == state.value.collection?.id) {
|
||||||
|
refreshCollection()
|
||||||
|
} else if (position != null) {
|
||||||
refreshItem(itemId, position, false)
|
refreshItem(itemId, position, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun refreshCollection() {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
val collection =
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(itemId)
|
||||||
|
.content
|
||||||
|
.let { BaseItem(it, false) }
|
||||||
|
_state.update { it.copy(collection = collection) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun canDelete(
|
fun canDelete(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
appPreferences: AppPreferences,
|
appPreferences: AppPreferences,
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ fun DiscoverPersonPage(
|
||||||
showJumpButtons = false,
|
showJumpButtons = false,
|
||||||
showLetterButtons = false,
|
showLetterButtons = false,
|
||||||
spacing = 16.dp,
|
spacing = 16.dp,
|
||||||
cardContent = @Composable { item, onClick, onLongClick, mod ->
|
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||||
DiscoverItemCard(
|
DiscoverItemCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
|
||||||
|
|
@ -31,33 +31,27 @@ import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
||||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.Optional
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
import com.github.damontecres.wholphin.ui.components.chooseStream
|
|
||||||
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
|
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -82,10 +76,8 @@ fun EpisodeDetails(
|
||||||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
val canDelete by viewModel.canDelete.collectAsState()
|
val canDelete by viewModel.canDelete.collectAsState()
|
||||||
|
|
||||||
|
|
@ -96,22 +88,36 @@ fun EpisodeDetails(
|
||||||
?.configuration
|
?.configuration
|
||||||
?.subtitleLanguagePreference
|
?.subtitleLanguagePreference
|
||||||
|
|
||||||
val moreActions =
|
val contextActions =
|
||||||
MoreDialogActions(
|
remember {
|
||||||
navigateTo = viewModel::navigateTo,
|
ContextMenuActions(
|
||||||
onClickWatch = { itemId, watched ->
|
navigateTo = viewModel::navigateTo,
|
||||||
viewModel.setWatched(itemId, watched)
|
onClickWatch = viewModel::setWatched,
|
||||||
},
|
onClickFavorite = viewModel::setFavorite,
|
||||||
onClickFavorite = { itemId, favorite ->
|
onClickAddPlaylist = { itemId ->
|
||||||
viewModel.setFavorite(itemId, favorite)
|
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
},
|
showPlaylistDialog.makePresent(itemId)
|
||||||
onClickAddPlaylist = { itemId ->
|
},
|
||||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||||
showPlaylistDialog.makePresent(itemId)
|
onDeleteItem = viewModel::deleteItem,
|
||||||
},
|
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
onChooseVersion = { item, source ->
|
||||||
onClickDelete = { showDeleteDialog = it },
|
viewModel.savePlayVersion(
|
||||||
)
|
item,
|
||||||
|
source.id!!.toUUID(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onChooseTracks = { result ->
|
||||||
|
viewModel.saveTrackSelection(
|
||||||
|
result.item,
|
||||||
|
result.itemPlayback,
|
||||||
|
result.trackIndex,
|
||||||
|
result.streamType,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClearChosenStreams = { viewModel.clearChosenStreams(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
|
|
@ -152,75 +158,17 @@ fun EpisodeDetails(
|
||||||
ItemDetailsDialogInfo(ep)
|
ItemDetailsDialogInfo(ep)
|
||||||
},
|
},
|
||||||
moreOnClick = {
|
moreOnClick = {
|
||||||
moreDialog =
|
showContextMenu =
|
||||||
DialogParams(
|
ContextMenu.ForBaseItem(
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
title = ep.name + " (${ep.data.productionYear ?: ""})",
|
item = ep,
|
||||||
items =
|
chosenStreams = chosenStreams,
|
||||||
buildMoreDialogItems(
|
showGoTo = false,
|
||||||
context = context,
|
showStreamChoices = true,
|
||||||
item = ep,
|
canDelete = canDelete,
|
||||||
watched = ep.data.userData?.played ?: false,
|
canRemoveContinueWatching = false,
|
||||||
favorite = ep.data.userData?.isFavorite ?: false,
|
canRemoveNextUp = false,
|
||||||
seriesId = ep.data.seriesId,
|
actions = contextActions,
|
||||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
|
||||||
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
|
||||||
actions = moreActions,
|
|
||||||
onChooseVersion = {
|
|
||||||
chooseVersion =
|
|
||||||
chooseVersionParams(
|
|
||||||
context,
|
|
||||||
ep.data.mediaSources!!,
|
|
||||||
chosenStreams?.source?.id?.toUUIDOrNull(),
|
|
||||||
) { idx ->
|
|
||||||
val source = ep.data.mediaSources!![idx]
|
|
||||||
viewModel.savePlayVersion(
|
|
||||||
ep,
|
|
||||||
source.id!!.toUUID(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
moreDialog = null
|
|
||||||
},
|
|
||||||
onChooseTracks = { type ->
|
|
||||||
viewModel.streamChoiceService
|
|
||||||
.chooseSource(
|
|
||||||
ep.data,
|
|
||||||
chosenStreams?.itemPlayback,
|
|
||||||
)?.let { source ->
|
|
||||||
chooseVersion =
|
|
||||||
chooseStream(
|
|
||||||
context = context,
|
|
||||||
streams = source.mediaStreams.orEmpty(),
|
|
||||||
currentIndex =
|
|
||||||
if (type == MediaStreamType.AUDIO) {
|
|
||||||
chosenStreams?.audioStream?.index
|
|
||||||
} else {
|
|
||||||
chosenStreams?.subtitleStream?.index
|
|
||||||
},
|
|
||||||
type = type,
|
|
||||||
onClick = { trackIndex ->
|
|
||||||
viewModel.saveTrackSelection(
|
|
||||||
ep,
|
|
||||||
chosenStreams?.itemPlayback,
|
|
||||||
trackIndex,
|
|
||||||
type,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onShowOverview = {
|
|
||||||
val source = chosenStreams?.source ?: ep.data.mediaSources?.firstOrNull()
|
|
||||||
if (source != null) {
|
|
||||||
overviewDialog = ItemDetailsDialogInfo(ep)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClearChosenStreams = {
|
|
||||||
viewModel.clearChosenStreams(chosenStreams)
|
|
||||||
},
|
|
||||||
canDelete = canDelete,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
watchOnClick = {
|
watchOnClick = {
|
||||||
|
|
@ -230,7 +178,7 @@ fun EpisodeDetails(
|
||||||
viewModel.setFavorite(ep.id, !ep.favorite)
|
viewModel.setFavorite(ep.id, !ep.favorite)
|
||||||
},
|
},
|
||||||
canDelete = canDelete,
|
canDelete = canDelete,
|
||||||
deleteOnClick = { showDeleteDialog = ep },
|
onConfirmDelete = { viewModel.deleteItem(ep) },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -246,24 +194,12 @@ fun EpisodeDetails(
|
||||||
onDismissRequest = { overviewDialog = null },
|
onDismissRequest = { overviewDialog = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
moreDialog?.let { params ->
|
showContextMenu?.let { contextMenu ->
|
||||||
DialogPopup(
|
ContextMenuDialog(
|
||||||
showDialog = true,
|
onDismissRequest = { showContextMenu = null },
|
||||||
title = params.title,
|
getMediaSource = viewModel.streamChoiceService::chooseSource,
|
||||||
dialogItems = params.items,
|
contextMenu = contextMenu,
|
||||||
onDismissRequest = { moreDialog = null },
|
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||||
dismissOnClick = true,
|
|
||||||
waitToLoad = params.fromLongClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
chooseVersion?.let { params ->
|
|
||||||
DialogPopup(
|
|
||||||
showDialog = true,
|
|
||||||
title = params.title,
|
|
||||||
dialogItems = params.items,
|
|
||||||
onDismissRequest = { chooseVersion = null },
|
|
||||||
dismissOnClick = true,
|
|
||||||
waitToLoad = params.fromLongClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showPlaylistDialog.compose { itemId ->
|
showPlaylistDialog.compose { itemId ->
|
||||||
|
|
@ -283,16 +219,6 @@ fun EpisodeDetails(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showDeleteDialog?.let { item ->
|
|
||||||
ConfirmDeleteDialog(
|
|
||||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
|
||||||
onCancel = { showDeleteDialog = null },
|
|
||||||
onConfirm = {
|
|
||||||
viewModel.deleteItem(item)
|
|
||||||
showDeleteDialog = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val HEADER_ROW = 0
|
private const val HEADER_ROW = 0
|
||||||
|
|
@ -308,7 +234,7 @@ fun EpisodeDetailsContent(
|
||||||
favoriteOnClick: () -> Unit,
|
favoriteOnClick: () -> Unit,
|
||||||
moreOnClick: () -> Unit,
|
moreOnClick: () -> Unit,
|
||||||
canDelete: Boolean,
|
canDelete: Boolean,
|
||||||
deleteOnClick: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -346,6 +272,7 @@ fun EpisodeDetailsContent(
|
||||||
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
|
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
|
||||||
)
|
)
|
||||||
ExpandablePlayButtons(
|
ExpandablePlayButtons(
|
||||||
|
title = ep.title ?: "",
|
||||||
resumePosition = resumePosition,
|
resumePosition = resumePosition,
|
||||||
watched = dto.userData?.played ?: false,
|
watched = dto.userData?.played ?: false,
|
||||||
favorite = dto.userData?.isFavorite ?: false,
|
favorite = dto.userData?.isFavorite ?: false,
|
||||||
|
|
@ -367,7 +294,7 @@ fun EpisodeDetailsContent(
|
||||||
trailers = null,
|
trailers = null,
|
||||||
trailerOnClick = {},
|
trailerOnClick = {},
|
||||||
canDelete = canDelete,
|
canDelete = canDelete,
|
||||||
deleteOnClick = deleteOnClick,
|
onConfirmDelete = onConfirmDelete,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.services.deleteItem
|
import com.github.damontecres.wholphin.services.deleteItem
|
||||||
import com.github.damontecres.wholphin.ui.combinePair
|
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
|
@ -38,9 +37,6 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -81,13 +77,9 @@ class EpisodeViewModel
|
||||||
init {
|
init {
|
||||||
init()
|
init()
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
item
|
mediaManagementService.collectCanDelete(item.asFlow()) { canDelete ->
|
||||||
.asFlow()
|
this@EpisodeViewModel.canDelete.update { canDelete }
|
||||||
.filterNotNull()
|
}
|
||||||
.combinePair(userPreferencesService.flow.map { it.appPreferences })
|
|
||||||
.collectLatest { (item, preferences) ->
|
|
||||||
canDelete.update { mediaManagementService.canDelete(item, preferences) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,25 +45,20 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
||||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.Optional
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
import com.github.damontecres.wholphin.ui.components.chooseStream
|
import com.github.damontecres.wholphin.ui.components.PersonContextActions
|
||||||
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
|
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
|
@ -74,11 +69,9 @@ import com.github.damontecres.wholphin.util.DiscoverRequestType
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -101,11 +94,9 @@ fun MovieDetails(
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
|
||||||
|
|
||||||
val preferredSubtitleLanguage =
|
val preferredSubtitleLanguage =
|
||||||
viewModel.serverRepository.currentUserDto
|
viewModel.serverRepository.currentUserDto
|
||||||
|
|
@ -114,22 +105,36 @@ fun MovieDetails(
|
||||||
?.configuration
|
?.configuration
|
||||||
?.subtitleLanguagePreference
|
?.subtitleLanguagePreference
|
||||||
|
|
||||||
val moreActions =
|
val contextActions =
|
||||||
MoreDialogActions(
|
remember {
|
||||||
navigateTo = viewModel::navigateTo,
|
ContextMenuActions(
|
||||||
onClickWatch = { itemId, watched ->
|
navigateTo = viewModel::navigateTo,
|
||||||
viewModel.setWatched(itemId, watched)
|
onClickWatch = viewModel::setWatched,
|
||||||
},
|
onClickFavorite = viewModel::setFavorite,
|
||||||
onClickFavorite = { itemId, favorite ->
|
onClickAddPlaylist = { itemId ->
|
||||||
viewModel.setFavorite(itemId, favorite)
|
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
},
|
showPlaylistDialog.makePresent(itemId)
|
||||||
onClickAddPlaylist = { itemId ->
|
},
|
||||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||||
showPlaylistDialog.makePresent(itemId)
|
onDeleteItem = viewModel::deleteItem,
|
||||||
},
|
onChooseVersion = { item, source ->
|
||||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
viewModel.savePlayVersion(
|
||||||
onClickDelete = { showDeleteDialog = it },
|
item,
|
||||||
)
|
source.id!!.toUUID(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onChooseTracks = { result ->
|
||||||
|
viewModel.saveTrackSelection(
|
||||||
|
result.item,
|
||||||
|
result.itemPlayback,
|
||||||
|
result.trackIndex,
|
||||||
|
result.streamType,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||||
|
onClearChosenStreams = { viewModel.clearChosenStreams(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
when (val s = state.loading) {
|
when (val s = state.loading) {
|
||||||
is DataLoadingState.Error -> {
|
is DataLoadingState.Error -> {
|
||||||
|
|
@ -183,72 +188,17 @@ fun MovieDetails(
|
||||||
ItemDetailsDialogInfo(movie)
|
ItemDetailsDialogInfo(movie)
|
||||||
},
|
},
|
||||||
moreOnClick = {
|
moreOnClick = {
|
||||||
moreDialog =
|
showContextMenu =
|
||||||
DialogParams(
|
ContextMenu.ForBaseItem(
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
title = movie.name + " (${movie.data.productionYear ?: ""})",
|
item = movie,
|
||||||
items =
|
chosenStreams = chosenStreams,
|
||||||
buildMoreDialogItems(
|
showGoTo = false,
|
||||||
context = context,
|
showStreamChoices = true,
|
||||||
item = movie,
|
canDelete = state.canDelete,
|
||||||
watched = movie.data.userData?.played ?: false,
|
canRemoveContinueWatching = false,
|
||||||
favorite = movie.data.userData?.isFavorite ?: false,
|
canRemoveNextUp = false,
|
||||||
seriesId = null,
|
actions = contextActions,
|
||||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
|
||||||
canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
|
|
||||||
canDelete = state.canDelete,
|
|
||||||
actions = moreActions,
|
|
||||||
onChooseVersion = {
|
|
||||||
chooseVersion =
|
|
||||||
chooseVersionParams(
|
|
||||||
context,
|
|
||||||
movie.data.mediaSources!!,
|
|
||||||
chosenStreams?.source?.id?.toUUIDOrNull(),
|
|
||||||
) { idx ->
|
|
||||||
val source = movie.data.mediaSources!![idx]
|
|
||||||
viewModel.savePlayVersion(
|
|
||||||
movie,
|
|
||||||
source.id!!.toUUID(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
moreDialog = null
|
|
||||||
},
|
|
||||||
onChooseTracks = { type ->
|
|
||||||
viewModel.streamChoiceService
|
|
||||||
.chooseSource(
|
|
||||||
movie.data,
|
|
||||||
chosenStreams?.itemPlayback,
|
|
||||||
)?.let { source ->
|
|
||||||
chooseVersion =
|
|
||||||
chooseStream(
|
|
||||||
context = context,
|
|
||||||
streams = source.mediaStreams.orEmpty(),
|
|
||||||
type = type,
|
|
||||||
currentIndex =
|
|
||||||
if (type == MediaStreamType.AUDIO) {
|
|
||||||
chosenStreams?.audioStream?.index
|
|
||||||
} else {
|
|
||||||
chosenStreams?.subtitleStream?.index
|
|
||||||
},
|
|
||||||
onClick = { trackIndex ->
|
|
||||||
viewModel.saveTrackSelection(
|
|
||||||
movie,
|
|
||||||
chosenStreams?.itemPlayback,
|
|
||||||
trackIndex,
|
|
||||||
type,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onShowOverview = {
|
|
||||||
overviewDialog = ItemDetailsDialogInfo(movie)
|
|
||||||
},
|
|
||||||
onClearChosenStreams = {
|
|
||||||
viewModel.clearChosenStreams(chosenStreams)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
watchOnClick = {
|
watchOnClick = {
|
||||||
|
|
@ -258,36 +208,29 @@ fun MovieDetails(
|
||||||
viewModel.setFavorite(movie.id, !movie.favorite)
|
viewModel.setFavorite(movie.id, !movie.favorite)
|
||||||
},
|
},
|
||||||
onLongClickPerson = { index, person ->
|
onLongClickPerson = { index, person ->
|
||||||
val items =
|
showContextMenu =
|
||||||
buildMoreDialogItemsForPerson(
|
ContextMenu.ForPerson(
|
||||||
context = context,
|
|
||||||
person = person,
|
|
||||||
actions = moreActions,
|
|
||||||
)
|
|
||||||
moreDialog =
|
|
||||||
DialogParams(
|
|
||||||
fromLongClick = true,
|
fromLongClick = true,
|
||||||
title = person.name ?: "",
|
person = person,
|
||||||
items = items,
|
actions =
|
||||||
|
PersonContextActions(
|
||||||
|
navigateTo = viewModel::navigateTo,
|
||||||
|
onClickFavorite = viewModel::setFavorite,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onLongClickSimilar = { index, similar ->
|
onLongClickSimilar = { _, similar ->
|
||||||
val items =
|
showContextMenu =
|
||||||
buildMoreDialogItemsForHome(
|
ContextMenu.ForBaseItem(
|
||||||
context = context,
|
|
||||||
item = similar,
|
|
||||||
seriesId = null,
|
|
||||||
playbackPosition = similar.playbackPosition,
|
|
||||||
watched = similar.played,
|
|
||||||
favorite = similar.favorite,
|
|
||||||
canDelete = false,
|
|
||||||
actions = moreActions,
|
|
||||||
)
|
|
||||||
moreDialog =
|
|
||||||
DialogParams(
|
|
||||||
fromLongClick = true,
|
fromLongClick = true,
|
||||||
title = similar.title ?: "",
|
item = similar,
|
||||||
items = items,
|
chosenStreams = null,
|
||||||
|
showGoTo = true,
|
||||||
|
showStreamChoices = false,
|
||||||
|
canDelete = false,
|
||||||
|
canRemoveContinueWatching = false,
|
||||||
|
canRemoveNextUp = false,
|
||||||
|
actions = contextActions,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailerOnClick = {
|
trailerOnClick = {
|
||||||
|
|
@ -300,11 +243,19 @@ fun MovieDetails(
|
||||||
viewModel.navigateTo(item.destination)
|
viewModel.navigateTo(item.destination)
|
||||||
},
|
},
|
||||||
canDelete = state.canDelete,
|
canDelete = state.canDelete,
|
||||||
deleteOnClick = { showDeleteDialog = state.movie },
|
onConfirmDelete = { state.movie?.let { viewModel.deleteItem(it) } },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
showContextMenu?.let { contextMenu ->
|
||||||
|
ContextMenuDialog(
|
||||||
|
onDismissRequest = { showContextMenu = null },
|
||||||
|
getMediaSource = viewModel.streamChoiceService::chooseSource,
|
||||||
|
contextMenu = contextMenu,
|
||||||
|
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||||
|
)
|
||||||
|
}
|
||||||
overviewDialog?.let { info ->
|
overviewDialog?.let { info ->
|
||||||
ItemDetailsDialog(
|
ItemDetailsDialog(
|
||||||
info = info,
|
info = info,
|
||||||
|
|
@ -315,26 +266,6 @@ fun MovieDetails(
|
||||||
onDismissRequest = { overviewDialog = null },
|
onDismissRequest = { overviewDialog = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
moreDialog?.let { params ->
|
|
||||||
DialogPopup(
|
|
||||||
showDialog = true,
|
|
||||||
title = params.title,
|
|
||||||
dialogItems = params.items,
|
|
||||||
onDismissRequest = { moreDialog = null },
|
|
||||||
dismissOnClick = true,
|
|
||||||
waitToLoad = params.fromLongClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
chooseVersion?.let { params ->
|
|
||||||
DialogPopup(
|
|
||||||
showDialog = true,
|
|
||||||
title = params.title,
|
|
||||||
dialogItems = params.items,
|
|
||||||
onDismissRequest = { chooseVersion = null },
|
|
||||||
dismissOnClick = true,
|
|
||||||
waitToLoad = params.fromLongClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
showPlaylistDialog.compose { itemId ->
|
showPlaylistDialog.compose { itemId ->
|
||||||
PlaylistDialog(
|
PlaylistDialog(
|
||||||
title = stringResource(R.string.add_to_playlist),
|
title = stringResource(R.string.add_to_playlist),
|
||||||
|
|
@ -352,16 +283,6 @@ fun MovieDetails(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showDeleteDialog?.let { item ->
|
|
||||||
ConfirmDeleteDialog(
|
|
||||||
itemTitle = item.title ?: "",
|
|
||||||
onCancel = { showDeleteDialog = null },
|
|
||||||
onConfirm = {
|
|
||||||
viewModel.deleteItem(item)
|
|
||||||
showDeleteDialog = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val HEADER_ROW = 0
|
private const val HEADER_ROW = 0
|
||||||
|
|
@ -390,7 +311,7 @@ fun MovieDetailsContent(
|
||||||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||||
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||||
canDelete: Boolean,
|
canDelete: Boolean,
|
||||||
deleteOnClick: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -430,6 +351,7 @@ fun MovieDetailsContent(
|
||||||
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
|
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
|
||||||
)
|
)
|
||||||
ExpandablePlayButtons(
|
ExpandablePlayButtons(
|
||||||
|
title = movie.title ?: "",
|
||||||
resumePosition = resumePosition,
|
resumePosition = resumePosition,
|
||||||
watched = dto.userData?.played ?: false,
|
watched = dto.userData?.played ?: false,
|
||||||
favorite = dto.userData?.isFavorite ?: false,
|
favorite = dto.userData?.isFavorite ?: false,
|
||||||
|
|
@ -454,7 +376,7 @@ fun MovieDetailsContent(
|
||||||
trailerOnClick.invoke(it)
|
trailerOnClick.invoke(it)
|
||||||
},
|
},
|
||||||
canDelete = canDelete,
|
canDelete = canDelete,
|
||||||
deleteOnClick = deleteOnClick,
|
onConfirmDelete = onConfirmDelete,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||||
|
|
@ -85,18 +85,9 @@ class MovieViewModel
|
||||||
init {
|
init {
|
||||||
init()
|
init()
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
userPreferencesService.flow.collectLatest { preferences ->
|
mediaManagementService.collectCanDelete(state.map { it.movie }) { canDelete ->
|
||||||
_state.update {
|
_state.update {
|
||||||
val canDelete =
|
it.copy(canDelete = canDelete)
|
||||||
it.movie?.let {
|
|
||||||
mediaManagementService.canDelete(
|
|
||||||
it,
|
|
||||||
preferences.appPreferences,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
it.copy(
|
|
||||||
canDelete = canDelete ?: false,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,12 +61,12 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.MusicContextActions
|
||||||
import com.github.damontecres.wholphin.ui.components.Optional
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
|
|
@ -94,6 +94,7 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -136,6 +137,11 @@ class AlbumViewModel
|
||||||
|
|
||||||
init {
|
init {
|
||||||
init()
|
init()
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
mediaManagementService.collectCanDelete(state.map { it.album }) { canDelete ->
|
||||||
|
_state.update { it.copy(canDelete = canDelete) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun init() {
|
override fun init() {
|
||||||
|
|
@ -275,6 +281,7 @@ data class AlbumState(
|
||||||
val similar: List<BaseItem>,
|
val similar: List<BaseItem>,
|
||||||
val loading: LoadingState,
|
val loading: LoadingState,
|
||||||
val musicVideos: List<BaseItem?> = emptyList(),
|
val musicVideos: List<BaseItem?> = emptyList(),
|
||||||
|
val canDelete: Boolean = false,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val EMPTY = AlbumState(null, false, null, emptyList(), emptyList(), LoadingState.Pending)
|
val EMPTY = AlbumState(null, false, null, emptyList(), emptyList(), LoadingState.Pending)
|
||||||
|
|
@ -308,22 +315,21 @@ fun AlbumDetailsPage(
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
|
||||||
val moreDialogActions =
|
val moreDialogActions =
|
||||||
remember {
|
remember {
|
||||||
MusicMoreDialogActions(
|
MusicContextActions(
|
||||||
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||||
onClickPlay = { index, _ -> viewModel.play(false, index) },
|
onClickPlay = { index, _ -> viewModel.play(false, index) },
|
||||||
onClickPlayNext = { _, song -> viewModel.playNext(song) },
|
onClickPlayNext = { _, song -> viewModel.playNext(song) },
|
||||||
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) },
|
onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) },
|
||||||
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
|
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
|
||||||
onClickAddPlaylist = { itemId ->
|
onClickAddPlaylist = { itemId ->
|
||||||
playlistViewModel.loadPlaylists(MediaType.AUDIO)
|
playlistViewModel.loadPlaylists(MediaType.AUDIO)
|
||||||
showPlaylistDialog.makePresent(itemId)
|
showPlaylistDialog.makePresent(itemId)
|
||||||
},
|
},
|
||||||
onClickRemoveFromQueue = {},
|
onClickRemoveFromQueue = { _, _ -> },
|
||||||
onClickDelete = { showDeleteDialog = it },
|
onDeleteItem = viewModel::deleteItem,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (val loading = state.loading) {
|
when (val loading = state.loading) {
|
||||||
|
|
@ -390,6 +396,7 @@ fun AlbumDetailsPage(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
MusicExpandableButtons(
|
MusicExpandableButtons(
|
||||||
|
title = album.title ?: "",
|
||||||
actions =
|
actions =
|
||||||
remember {
|
remember {
|
||||||
MusicButtonActions(
|
MusicButtonActions(
|
||||||
|
|
@ -402,28 +409,27 @@ fun AlbumDetailsPage(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClickMore = {
|
onClickMore = {
|
||||||
moreDialog =
|
showContextMenu =
|
||||||
DialogParams(
|
ContextMenu.ForMusic(
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
title = album.name + " (${album.data.productionYear ?: ""})",
|
item = album,
|
||||||
items =
|
index = 0,
|
||||||
buildMoreDialogForMusic(
|
canDelete =
|
||||||
context = context,
|
viewModel.canDelete(
|
||||||
actions = moreDialogActions,
|
album,
|
||||||
item = album,
|
preferences.appPreferences,
|
||||||
index = 0,
|
|
||||||
canRemove = false,
|
|
||||||
canDelete =
|
|
||||||
viewModel.canDelete(
|
|
||||||
album,
|
|
||||||
preferences.appPreferences,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
canRemoveFromQueue = false,
|
||||||
|
actions = moreDialogActions,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onConfirmDelete = {
|
||||||
|
viewModel.deleteItem(album)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
favorite = album.favorite,
|
favorite = album.favorite,
|
||||||
|
canDelete = state.canDelete,
|
||||||
buttonOnFocusChanged = {
|
buttonOnFocusChanged = {
|
||||||
if (it.isFocused) {
|
if (it.isFocused) {
|
||||||
position = RowColumn(HEADER_ROW, 0)
|
position = RowColumn(HEADER_ROW, 0)
|
||||||
|
|
@ -459,45 +465,35 @@ fun AlbumDetailsPage(
|
||||||
},
|
},
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
if (song != null) {
|
if (song != null) {
|
||||||
moreDialog =
|
showContextMenu =
|
||||||
DialogParams(
|
ContextMenu.ForMusic(
|
||||||
fromLongClick = true,
|
fromLongClick = true,
|
||||||
title = song.name ?: "",
|
item = song,
|
||||||
items =
|
index = index,
|
||||||
buildMoreDialogForMusic(
|
canDelete =
|
||||||
context = context,
|
viewModel.canDelete(
|
||||||
actions = moreDialogActions,
|
song,
|
||||||
item = song,
|
preferences.appPreferences,
|
||||||
index = index,
|
|
||||||
canRemove = false,
|
|
||||||
canDelete =
|
|
||||||
viewModel.canDelete(
|
|
||||||
song,
|
|
||||||
preferences.appPreferences,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
canRemoveFromQueue = false,
|
||||||
|
actions = moreDialogActions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClickMore = {
|
onClickMore = {
|
||||||
if (song != null) {
|
if (song != null) {
|
||||||
moreDialog =
|
showContextMenu =
|
||||||
DialogParams(
|
ContextMenu.ForMusic(
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
title = song.name ?: "",
|
item = song,
|
||||||
items =
|
index = index,
|
||||||
buildMoreDialogForMusic(
|
canDelete =
|
||||||
context = context,
|
viewModel.canDelete(
|
||||||
actions = moreDialogActions,
|
song,
|
||||||
item = song,
|
preferences.appPreferences,
|
||||||
index = index,
|
|
||||||
canRemove = false,
|
|
||||||
canDelete =
|
|
||||||
viewModel.canDelete(
|
|
||||||
song,
|
|
||||||
preferences.appPreferences,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
canRemoveFromQueue = false,
|
||||||
|
actions = moreDialogActions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -559,23 +555,18 @@ fun AlbumDetailsPage(
|
||||||
viewModel.navigationManager.navigateTo(item.destination())
|
viewModel.navigationManager.navigateTo(item.destination())
|
||||||
},
|
},
|
||||||
onLongClickItem = { index, item ->
|
onLongClickItem = { index, item ->
|
||||||
moreDialog =
|
showContextMenu =
|
||||||
DialogParams(
|
ContextMenu.ForMusic(
|
||||||
fromLongClick = true,
|
fromLongClick = true,
|
||||||
title = item.name ?: "",
|
item = item,
|
||||||
items =
|
index = index,
|
||||||
buildMoreDialogForMusic(
|
canDelete =
|
||||||
context = context,
|
viewModel.canDelete(
|
||||||
actions = moreDialogActions,
|
item,
|
||||||
item = item,
|
preferences.appPreferences,
|
||||||
index = index,
|
|
||||||
canRemove = false,
|
|
||||||
canDelete =
|
|
||||||
viewModel.canDelete(
|
|
||||||
item,
|
|
||||||
preferences.appPreferences,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
canRemoveFromQueue = false,
|
||||||
|
actions = moreDialogActions,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||||
|
|
@ -597,14 +588,12 @@ fun AlbumDetailsPage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
moreDialog?.let { params ->
|
showContextMenu?.let { contextMenu ->
|
||||||
DialogPopup(
|
ContextMenuDialog(
|
||||||
showDialog = true,
|
onDismissRequest = { showContextMenu = null },
|
||||||
title = params.title,
|
getMediaSource = null,
|
||||||
dialogItems = params.items,
|
contextMenu = contextMenu,
|
||||||
onDismissRequest = { moreDialog = null },
|
preferredSubtitleLanguage = null,
|
||||||
dismissOnClick = true,
|
|
||||||
waitToLoad = params.fromLongClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showPlaylistDialog.compose { itemId ->
|
showPlaylistDialog.compose { itemId ->
|
||||||
|
|
@ -624,17 +613,6 @@ fun AlbumDetailsPage(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showDeleteDialog?.let { item ->
|
|
||||||
ConfirmDeleteDialog(
|
|
||||||
itemTitle = item.title ?: "",
|
|
||||||
onCancel = { showDeleteDialog = null },
|
|
||||||
onConfirm = {
|
|
||||||
viewModel.deleteItem(item)
|
|
||||||
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
|
|
||||||
showDeleteDialog = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,12 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.MusicContextActions
|
||||||
import com.github.damontecres.wholphin.ui.components.Optional
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
|
|
@ -92,6 +92,7 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -135,6 +136,11 @@ class ArtistViewModel
|
||||||
|
|
||||||
init {
|
init {
|
||||||
init()
|
init()
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
mediaManagementService.collectCanDelete(state.map { it.artist }) { canDelete ->
|
||||||
|
_state.update { it.copy(canDelete = canDelete) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun init() {
|
override fun init() {
|
||||||
|
|
@ -269,6 +275,7 @@ data class ArtistState(
|
||||||
val similar: List<BaseItem>,
|
val similar: List<BaseItem>,
|
||||||
val loading: LoadingState,
|
val loading: LoadingState,
|
||||||
val musicVideos: List<BaseItem?>,
|
val musicVideos: List<BaseItem?>,
|
||||||
|
val canDelete: Boolean,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val EMPTY =
|
val EMPTY =
|
||||||
|
|
@ -280,6 +287,7 @@ data class ArtistState(
|
||||||
emptyList(),
|
emptyList(),
|
||||||
LoadingState.Pending,
|
LoadingState.Pending,
|
||||||
emptyList(),
|
emptyList(),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -312,22 +320,21 @@ fun ArtistDetailsPage(
|
||||||
|
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
|
||||||
val moreDialogActions =
|
val moreDialogActions =
|
||||||
remember {
|
remember {
|
||||||
MusicMoreDialogActions(
|
MusicContextActions(
|
||||||
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||||
onClickPlay = { index, item -> viewModel.play(item) },
|
onClickPlay = { index, item -> viewModel.play(item) },
|
||||||
onClickPlayNext = { _, item -> viewModel.playNext(item) },
|
onClickPlayNext = { _, item -> viewModel.playNext(item) },
|
||||||
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) },
|
onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) },
|
||||||
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
|
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
|
||||||
onClickAddPlaylist = { itemId ->
|
onClickAddPlaylist = { itemId ->
|
||||||
playlistViewModel.loadPlaylists(MediaType.AUDIO)
|
playlistViewModel.loadPlaylists(MediaType.AUDIO)
|
||||||
showPlaylistDialog.makePresent(itemId)
|
showPlaylistDialog.makePresent(itemId)
|
||||||
},
|
},
|
||||||
onClickRemoveFromQueue = {},
|
onClickRemoveFromQueue = { _, _ -> },
|
||||||
onClickDelete = { showDeleteDialog = it },
|
onDeleteItem = viewModel::deleteItem,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,6 +386,7 @@ fun ArtistDetailsPage(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
MusicExpandableButtons(
|
MusicExpandableButtons(
|
||||||
|
title = artist.title ?: "",
|
||||||
actions =
|
actions =
|
||||||
remember {
|
remember {
|
||||||
MusicButtonActions(
|
MusicButtonActions(
|
||||||
|
|
@ -393,28 +401,25 @@ fun ArtistDetailsPage(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClickMore = {
|
onClickMore = {
|
||||||
moreDialog =
|
showContextMenu =
|
||||||
DialogParams(
|
ContextMenu.ForMusic(
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
title = artist.name ?: "",
|
item = artist,
|
||||||
items =
|
index = 0,
|
||||||
buildMoreDialogForMusic(
|
canDelete =
|
||||||
context = context,
|
viewModel.canDelete(
|
||||||
actions = moreDialogActions,
|
artist,
|
||||||
item = artist,
|
preferences.appPreferences,
|
||||||
index = 0,
|
|
||||||
canRemove = false,
|
|
||||||
canDelete =
|
|
||||||
viewModel.canDelete(
|
|
||||||
artist,
|
|
||||||
preferences.appPreferences,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
canRemoveFromQueue = false,
|
||||||
|
actions = moreDialogActions,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onConfirmDelete = { viewModel.deleteItem(artist) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
favorite = artist.favorite,
|
favorite = artist.favorite,
|
||||||
|
canDelete = state.canDelete,
|
||||||
buttonOnFocusChanged = {
|
buttonOnFocusChanged = {
|
||||||
if (it.isFocused) {
|
if (it.isFocused) {
|
||||||
position = RowColumn(HEADER_ROW, 0)
|
position = RowColumn(HEADER_ROW, 0)
|
||||||
|
|
@ -451,45 +456,35 @@ fun ArtistDetailsPage(
|
||||||
},
|
},
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
if (song != null) {
|
if (song != null) {
|
||||||
moreDialog =
|
showContextMenu =
|
||||||
DialogParams(
|
ContextMenu.ForMusic(
|
||||||
fromLongClick = true,
|
fromLongClick = true,
|
||||||
title = song.name ?: "",
|
item = song,
|
||||||
items =
|
index = index,
|
||||||
buildMoreDialogForMusic(
|
canDelete =
|
||||||
context = context,
|
viewModel.canDelete(
|
||||||
actions = moreDialogActions,
|
song,
|
||||||
item = song,
|
preferences.appPreferences,
|
||||||
index = index,
|
|
||||||
canRemove = false,
|
|
||||||
canDelete =
|
|
||||||
viewModel.canDelete(
|
|
||||||
song,
|
|
||||||
preferences.appPreferences,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
canRemoveFromQueue = false,
|
||||||
|
actions = moreDialogActions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClickMore = {
|
onClickMore = {
|
||||||
if (song != null) {
|
if (song != null) {
|
||||||
moreDialog =
|
showContextMenu =
|
||||||
DialogParams(
|
ContextMenu.ForMusic(
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
title = song.name ?: "",
|
item = song,
|
||||||
items =
|
index = index,
|
||||||
buildMoreDialogForMusic(
|
canDelete =
|
||||||
context = context,
|
viewModel.canDelete(
|
||||||
actions = moreDialogActions,
|
song,
|
||||||
item = song,
|
preferences.appPreferences,
|
||||||
index = index,
|
|
||||||
canRemove = false,
|
|
||||||
canDelete =
|
|
||||||
viewModel.canDelete(
|
|
||||||
song,
|
|
||||||
preferences.appPreferences,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
canRemoveFromQueue = false,
|
||||||
|
actions = moreDialogActions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -516,23 +511,18 @@ fun ArtistDetailsPage(
|
||||||
viewModel.navigationManager.navigateTo(album.destination())
|
viewModel.navigationManager.navigateTo(album.destination())
|
||||||
},
|
},
|
||||||
onLongClickItem = { index, album ->
|
onLongClickItem = { index, album ->
|
||||||
moreDialog =
|
showContextMenu =
|
||||||
DialogParams(
|
ContextMenu.ForMusic(
|
||||||
fromLongClick = true,
|
fromLongClick = true,
|
||||||
title = album.name ?: "",
|
item = album,
|
||||||
items =
|
index = index,
|
||||||
buildMoreDialogForMusic(
|
canDelete =
|
||||||
context = context,
|
viewModel.canDelete(
|
||||||
actions = moreDialogActions,
|
album,
|
||||||
item = album,
|
preferences.appPreferences,
|
||||||
index = index,
|
|
||||||
canRemove = false,
|
|
||||||
canDelete =
|
|
||||||
viewModel.canDelete(
|
|
||||||
album,
|
|
||||||
preferences.appPreferences,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
canRemoveFromQueue = false,
|
||||||
|
actions = moreDialogActions,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||||
|
|
@ -588,23 +578,18 @@ fun ArtistDetailsPage(
|
||||||
viewModel.navigationManager.navigateTo(item.destination())
|
viewModel.navigationManager.navigateTo(item.destination())
|
||||||
},
|
},
|
||||||
onLongClickItem = { index, item ->
|
onLongClickItem = { index, item ->
|
||||||
moreDialog =
|
showContextMenu =
|
||||||
DialogParams(
|
ContextMenu.ForMusic(
|
||||||
fromLongClick = true,
|
fromLongClick = true,
|
||||||
title = item.name ?: "",
|
item = item,
|
||||||
items =
|
index = index,
|
||||||
buildMoreDialogForMusic(
|
canDelete =
|
||||||
context = context,
|
viewModel.canDelete(
|
||||||
actions = moreDialogActions,
|
item,
|
||||||
item = item,
|
preferences.appPreferences,
|
||||||
index = index,
|
|
||||||
canRemove = false,
|
|
||||||
canDelete =
|
|
||||||
viewModel.canDelete(
|
|
||||||
item,
|
|
||||||
preferences.appPreferences,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
canRemoveFromQueue = false,
|
||||||
|
actions = moreDialogActions,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||||
|
|
@ -626,14 +611,12 @@ fun ArtistDetailsPage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
moreDialog?.let { params ->
|
showContextMenu?.let { contextMenu ->
|
||||||
DialogPopup(
|
ContextMenuDialog(
|
||||||
showDialog = true,
|
onDismissRequest = { showContextMenu = null },
|
||||||
title = params.title,
|
getMediaSource = null,
|
||||||
dialogItems = params.items,
|
contextMenu = contextMenu,
|
||||||
onDismissRequest = { moreDialog = null },
|
preferredSubtitleLanguage = null,
|
||||||
dismissOnClick = true,
|
|
||||||
waitToLoad = params.fromLongClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showPlaylistDialog.compose { itemId ->
|
showPlaylistDialog.compose { itemId ->
|
||||||
|
|
@ -653,17 +636,6 @@ fun ArtistDetailsPage(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showDeleteDialog?.let { item ->
|
|
||||||
ConfirmDeleteDialog(
|
|
||||||
itemTitle = item.title ?: "",
|
|
||||||
onCancel = { showDeleteDialog = null },
|
|
||||||
onConfirm = {
|
|
||||||
viewModel.deleteItem(item)
|
|
||||||
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
|
|
||||||
showDeleteDialog = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,17 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DeleteButton
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MusicExpandableButtons(
|
fun MusicExpandableButtons(
|
||||||
|
title: String,
|
||||||
actions: MusicButtonActions,
|
actions: MusicButtonActions,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
|
canDelete: Boolean,
|
||||||
buttonOnFocusChanged: (FocusState) -> Unit,
|
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -75,6 +78,15 @@ fun MusicExpandableButtons(
|
||||||
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (canDelete) {
|
||||||
|
item("delete") {
|
||||||
|
DeleteButton(
|
||||||
|
title = title,
|
||||||
|
onConfirmDelete = actions.onConfirmDelete,
|
||||||
|
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
item("more") {
|
item("more") {
|
||||||
ExpandablePlayButton(
|
ExpandablePlayButton(
|
||||||
title = R.string.more,
|
title = R.string.more,
|
||||||
|
|
@ -92,4 +104,5 @@ data class MusicButtonActions(
|
||||||
val onClickInstantMix: () -> Unit,
|
val onClickInstantMix: () -> Unit,
|
||||||
val onClickFavorite: () -> Unit,
|
val onClickFavorite: () -> Unit,
|
||||||
val onClickMore: () -> Unit,
|
val onClickMore: () -> Unit,
|
||||||
|
val onConfirmDelete: () -> Unit,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.music
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
|
@ -12,7 +11,6 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
@ -22,7 +20,6 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
|
@ -35,7 +32,6 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||||
import androidx.media3.ui.compose.state.rememberPreviousButtonState
|
import androidx.media3.ui.compose.state.rememberPreviousButtonState
|
||||||
import androidx.media3.ui.compose.state.rememberRepeatButtonState
|
import androidx.media3.ui.compose.state.rememberRepeatButtonState
|
||||||
import androidx.media3.ui.compose.state.rememberShuffleButtonState
|
import androidx.media3.ui.compose.state.rememberShuffleButtonState
|
||||||
import androidx.tv.material3.Border
|
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
@ -45,11 +41,10 @@ import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
import com.github.damontecres.wholphin.ui.components.Button
|
import com.github.damontecres.wholphin.ui.components.Button
|
||||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlaybackAction
|
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlaybackButton
|
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
|
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButtons
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
|
import com.github.damontecres.wholphin.ui.playback.overlay.buttonSpacing
|
||||||
import com.github.damontecres.wholphin.ui.playback.buttonSpacing
|
|
||||||
import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource
|
import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ import com.github.damontecres.wholphin.ui.components.Button
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
||||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||||
import com.github.damontecres.wholphin.ui.playback.SeekBar
|
import com.github.damontecres.wholphin.ui.playback.overlay.SeekBar
|
||||||
import com.github.damontecres.wholphin.ui.preferences.MoveButton
|
import com.github.damontecres.wholphin.ui.preferences.MoveButton
|
||||||
import com.github.damontecres.wholphin.ui.roundSeconds
|
import com.github.damontecres.wholphin.ui.roundSeconds
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
|
|
||||||
|
|
@ -60,15 +60,16 @@ import com.github.damontecres.wholphin.preferences.BackdropStyle
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.rememberQueue
|
import com.github.damontecres.wholphin.services.rememberQueue
|
||||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.QueueContextActions
|
||||||
import com.github.damontecres.wholphin.ui.findActivity
|
import com.github.damontecres.wholphin.ui.findActivity
|
||||||
import com.github.damontecres.wholphin.ui.nav.Backdrop
|
import com.github.damontecres.wholphin.ui.nav.Backdrop
|
||||||
import com.github.damontecres.wholphin.ui.playback.BottomDialog
|
|
||||||
import com.github.damontecres.wholphin.ui.playback.BottomDialogItem
|
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
|
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
|
||||||
import com.github.damontecres.wholphin.ui.playback.isUp
|
import com.github.damontecres.wholphin.ui.playback.isUp
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
|
@ -129,18 +130,9 @@ fun NowPlayingPage(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val actions =
|
|
||||||
remember {
|
|
||||||
MusicQueueDialogActions(
|
|
||||||
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
|
||||||
onClickPlay = { index, _ -> viewModel.play(index) },
|
|
||||||
onClickPlayNext = { index, _ -> viewModel.playNext(index) },
|
|
||||||
onClickRemoveFromQueue = { index, _ -> viewModel.removeFromQueue(index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var showViewOptionsDialog by remember { mutableStateOf(false) }
|
var showViewOptionsDialog by remember { mutableStateOf(false) }
|
||||||
var itemMoreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu.ForQueue?>(null) }
|
||||||
|
|
||||||
var lyricsHaveFocus by remember { mutableStateOf(false) }
|
var lyricsHaveFocus by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
@ -209,6 +201,7 @@ fun NowPlayingPage(
|
||||||
remember {
|
remember {
|
||||||
shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut()
|
shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut()
|
||||||
}
|
}
|
||||||
|
//noinspection UnnecessaryFullyQualifiedName
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
visible = musicPrefs.showAlbumArt,
|
visible = musicPrefs.showAlbumArt,
|
||||||
enter = enter,
|
enter = enter,
|
||||||
|
|
@ -250,6 +243,7 @@ fun NowPlayingPage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
//noinspection UnnecessaryFullyQualifiedName
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
visible = musicPrefs.showVisualizer,
|
visible = musicPrefs.showVisualizer,
|
||||||
enter = enter,
|
enter = enter,
|
||||||
|
|
@ -308,17 +302,21 @@ fun NowPlayingPage(
|
||||||
val showContextForItem =
|
val showContextForItem =
|
||||||
remember {
|
remember {
|
||||||
{ fromLongClick: Boolean, index: Int, song: AudioItem ->
|
{ fromLongClick: Boolean, index: Int, song: AudioItem ->
|
||||||
itemMoreDialog =
|
showContextMenu =
|
||||||
DialogParams(
|
ContextMenu.ForQueue(
|
||||||
title = song.title ?: "",
|
|
||||||
fromLongClick = fromLongClick,
|
fromLongClick = fromLongClick,
|
||||||
items =
|
item = song,
|
||||||
buildMoreDialogForMusicQueue(
|
index = index,
|
||||||
context = context,
|
actions =
|
||||||
actions = actions,
|
QueueContextActions(
|
||||||
item = song,
|
onNavigate = { viewModel.navigationManager.navigateTo(it) },
|
||||||
index = index,
|
onClickPlay = { index, _ -> viewModel.play(index) },
|
||||||
canRemove = true,
|
onClickPlayNext = { index, _ -> viewModel.playNext(index) },
|
||||||
|
onClickRemoveFromQueue = { index, _ ->
|
||||||
|
viewModel.removeFromQueue(
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -368,14 +366,12 @@ fun NowPlayingPage(
|
||||||
LoadingPage(focusEnabled = false)
|
LoadingPage(focusEnabled = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemMoreDialog?.let { params ->
|
showContextMenu?.let { contextMenu ->
|
||||||
DialogPopup(
|
ContextMenuDialog(
|
||||||
showDialog = true,
|
onDismissRequest = { showContextMenu = null },
|
||||||
title = params.title,
|
getMediaSource = null,
|
||||||
dialogItems = params.items,
|
contextMenu = contextMenu,
|
||||||
onDismissRequest = { itemMoreDialog = null },
|
preferredSubtitleLanguage = null,
|
||||||
dismissOnClick = true,
|
|
||||||
waitToLoad = params.fromLongClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showViewOptionsDialog) {
|
if (showViewOptionsDialog) {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,8 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.music
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.ArrowForward
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.R
|
|
||||||
import com.github.damontecres.wholphin.data.model.AudioItem
|
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
|
||||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -23,193 +11,6 @@ import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class MusicMoreDialogActions(
|
|
||||||
val onNavigate: (Destination) -> Unit,
|
|
||||||
val onClickPlay: (Int, BaseItem) -> Unit,
|
|
||||||
val onClickPlayNext: (Int, BaseItem) -> Unit,
|
|
||||||
val onClickAddToQueue: (Int, BaseItem) -> Unit,
|
|
||||||
val onClickFavorite: (UUID, Boolean) -> Unit,
|
|
||||||
val onClickAddPlaylist: (UUID) -> Unit,
|
|
||||||
val onClickGoToAlbum: (UUID) -> Unit = {
|
|
||||||
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
|
|
||||||
},
|
|
||||||
val onClickGoToArtist: (UUID) -> Unit = {
|
|
||||||
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
|
|
||||||
},
|
|
||||||
val onClickRemoveFromQueue: (Int) -> Unit,
|
|
||||||
val onClickDelete: (BaseItem) -> Unit,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun buildMoreDialogForMusic(
|
|
||||||
context: Context,
|
|
||||||
actions: MusicMoreDialogActions,
|
|
||||||
item: BaseItem,
|
|
||||||
index: Int,
|
|
||||||
canRemove: Boolean,
|
|
||||||
canDelete: Boolean,
|
|
||||||
): List<DialogItem> =
|
|
||||||
buildList {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.play),
|
|
||||||
Icons.Default.PlayArrow,
|
|
||||||
iconColor = Color.Green.copy(alpha = .8f),
|
|
||||||
) {
|
|
||||||
actions.onClickPlay(index, item)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.play_next),
|
|
||||||
Icons.Default.PlayArrow,
|
|
||||||
iconColor = Color.Green.copy(alpha = .8f),
|
|
||||||
) {
|
|
||||||
actions.onClickPlayNext(index, item)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (canRemove) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.remove_from_queue),
|
|
||||||
Icons.Default.Delete,
|
|
||||||
) {
|
|
||||||
actions.onClickRemoveFromQueue(index)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.add_to_queue),
|
|
||||||
Icons.Default.Add,
|
|
||||||
) {
|
|
||||||
actions.onClickAddToQueue(index, item)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
text = R.string.add_to_playlist,
|
|
||||||
iconStringRes = R.string.fa_list_ul,
|
|
||||||
) {
|
|
||||||
actions.onClickAddPlaylist.invoke(item.id)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (canDelete) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.delete),
|
|
||||||
Icons.Default.Delete,
|
|
||||||
iconColor = Color.Red.copy(alpha = .8f),
|
|
||||||
) {
|
|
||||||
actions.onClickDelete.invoke(item)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
text = if (item.favorite) R.string.remove_favorite else R.string.add_favorite,
|
|
||||||
iconStringRes = R.string.fa_heart,
|
|
||||||
iconColor = if (item.favorite) Color.Red else Color.Unspecified,
|
|
||||||
) {
|
|
||||||
actions.onClickFavorite.invoke(item.id, !item.favorite)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.go_to_album),
|
|
||||||
R.string.fa_compact_disc,
|
|
||||||
) {
|
|
||||||
actions.onClickGoToAlbum.invoke(item.data.albumId!!)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.go_to_artist),
|
|
||||||
R.string.fa_user,
|
|
||||||
) {
|
|
||||||
actions.onClickGoToArtist.invoke(
|
|
||||||
item.data.artistItems!!
|
|
||||||
.first()
|
|
||||||
.id,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class MusicQueueDialogActions(
|
|
||||||
val onNavigate: (Destination) -> Unit,
|
|
||||||
val onClickPlay: (Int, AudioItem) -> Unit,
|
|
||||||
val onClickPlayNext: (Int, AudioItem) -> Unit,
|
|
||||||
val onClickGoToAlbum: (UUID) -> Unit = {
|
|
||||||
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
|
|
||||||
},
|
|
||||||
val onClickGoToArtist: (UUID) -> Unit = {
|
|
||||||
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
|
|
||||||
},
|
|
||||||
val onClickRemoveFromQueue: (Int, AudioItem) -> Unit,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun buildMoreDialogForMusicQueue(
|
|
||||||
context: Context,
|
|
||||||
actions: MusicQueueDialogActions,
|
|
||||||
item: AudioItem,
|
|
||||||
index: Int,
|
|
||||||
canRemove: Boolean,
|
|
||||||
): List<DialogItem> =
|
|
||||||
buildList {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.play),
|
|
||||||
Icons.Default.PlayArrow,
|
|
||||||
iconColor = Color.Green.copy(alpha = .8f),
|
|
||||||
) {
|
|
||||||
actions.onClickPlay(index, item)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.play_next),
|
|
||||||
Icons.Default.PlayArrow,
|
|
||||||
iconColor = Color.Green.copy(alpha = .8f),
|
|
||||||
) {
|
|
||||||
actions.onClickPlayNext(index, item)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (canRemove) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.remove_from_queue),
|
|
||||||
Icons.Default.Delete,
|
|
||||||
) {
|
|
||||||
actions.onClickRemoveFromQueue(index, item)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (item.albumId != null) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.go_to_album),
|
|
||||||
Icons.Default.ArrowForward,
|
|
||||||
) {
|
|
||||||
actions.onClickGoToAlbum.invoke(item.albumId)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (item.artistId != null) {
|
|
||||||
add(
|
|
||||||
DialogItem(
|
|
||||||
context.getString(R.string.go_to_artist),
|
|
||||||
Icons.Default.ArrowForward,
|
|
||||||
) {
|
|
||||||
actions.onClickGoToArtist.invoke(item.artistId)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun ViewModel.getPagerForAlbum(
|
suspend fun ViewModel.getPagerForAlbum(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
albumId: UUID,
|
albumId: UUID,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ fun FocusedEpisodeFooter(
|
||||||
watchOnClick: () -> Unit,
|
watchOnClick: () -> Unit,
|
||||||
favoriteOnClick: () -> Unit,
|
favoriteOnClick: () -> Unit,
|
||||||
canDelete: Boolean,
|
canDelete: Boolean,
|
||||||
deleteOnClick: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
buttonOnFocusChanged: (FocusState) -> Unit = {},
|
buttonOnFocusChanged: (FocusState) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
@ -39,6 +39,7 @@ fun FocusedEpisodeFooter(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
ExpandablePlayButtons(
|
ExpandablePlayButtons(
|
||||||
|
title = ep.title ?: "",
|
||||||
resumePosition = resumePosition,
|
resumePosition = resumePosition,
|
||||||
watched = dto.userData?.played ?: false,
|
watched = dto.userData?.played ?: false,
|
||||||
favorite = dto.userData?.isFavorite ?: false,
|
favorite = dto.userData?.isFavorite ?: false,
|
||||||
|
|
@ -50,7 +51,7 @@ fun FocusedEpisodeFooter(
|
||||||
trailers = null,
|
trailers = null,
|
||||||
trailerOnClick = {},
|
trailerOnClick = {},
|
||||||
canDelete = canDelete,
|
canDelete = canDelete,
|
||||||
deleteOnClick = deleteOnClick,
|
onConfirmDelete = onConfirmDelete,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
|
@ -57,12 +56,13 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.DeleteButton
|
import com.github.damontecres.wholphin.ui.components.DeleteButton
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||||
|
|
@ -71,20 +71,17 @@ import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.Optional
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
|
import com.github.damontecres.wholphin.ui.components.PersonContextActions
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||||
import com.github.damontecres.wholphin.ui.components.TrailerButton
|
import com.github.damontecres.wholphin.ui.components.TrailerButton
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
|
|
@ -95,6 +92,7 @@ import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -128,10 +126,50 @@ fun SeriesDetails(
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
|
|
||||||
var showWatchConfirmation by remember { mutableStateOf(false) }
|
var showWatchConfirmation by remember { mutableStateOf(false) }
|
||||||
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
|
||||||
|
val contextActions =
|
||||||
|
remember {
|
||||||
|
ContextMenuActions(
|
||||||
|
navigateTo = viewModel::navigateTo,
|
||||||
|
onClickWatch = { itemId, watched ->
|
||||||
|
if (itemId == destination.itemId) {
|
||||||
|
// Confirm if marking whole series
|
||||||
|
showWatchConfirmation = true
|
||||||
|
} else {
|
||||||
|
viewModel.setWatched(itemId, watched, null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClickFavorite = { itemId, favorite ->
|
||||||
|
viewModel.setFavorite(itemId, favorite, null)
|
||||||
|
},
|
||||||
|
onClickAddPlaylist = { itemId ->
|
||||||
|
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
|
showPlaylistDialog = Optional.present(itemId)
|
||||||
|
},
|
||||||
|
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||||
|
onDeleteItem = viewModel::deleteItem,
|
||||||
|
onChooseVersion = { item, source ->
|
||||||
|
viewModel.savePlayVersion(
|
||||||
|
item,
|
||||||
|
source.id!!.toUUID(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onChooseTracks = { result ->
|
||||||
|
viewModel.saveTrackSelection(
|
||||||
|
result.item,
|
||||||
|
result.itemPlayback,
|
||||||
|
result.trackIndex,
|
||||||
|
result.streamType,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||||
|
onClearChosenStreams = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LifecycleResumeEffect(destination.itemId) {
|
LifecycleResumeEffect(destination.itemId) {
|
||||||
viewModel.refresh()
|
viewModel.refresh()
|
||||||
|
|
@ -185,29 +223,18 @@ fun SeriesDetails(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onLongClickItem = { index, season ->
|
onLongClickItem = { index, season ->
|
||||||
scope.launchDefault {
|
showContextMenu =
|
||||||
seasonDialog =
|
ContextMenu.ForBaseItem(
|
||||||
buildDialogForSeason(
|
fromLongClick = true,
|
||||||
context = context,
|
item = season,
|
||||||
s = season,
|
chosenStreams = null,
|
||||||
canDelete = viewModel.canDelete(season),
|
showGoTo = true,
|
||||||
onClickItem = { viewModel.navigateTo(it.destination()) },
|
showStreamChoices = false,
|
||||||
markPlayed = { played ->
|
canDelete = viewModel.canDelete(season, preferences.appPreferences),
|
||||||
viewModel.setSeasonWatched(season.id, played)
|
canRemoveContinueWatching = false,
|
||||||
},
|
canRemoveNextUp = false,
|
||||||
onClickPlay = { shuffle ->
|
actions = contextActions,
|
||||||
viewModel.navigateTo(
|
)
|
||||||
Destination.PlaybackList(
|
|
||||||
itemId = season.id,
|
|
||||||
shuffle = shuffle,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClickDelete = {
|
|
||||||
showDeleteDialog = it
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
overviewOnClick = {
|
overviewOnClick = {
|
||||||
overviewDialog = ItemDetailsDialogInfo(item)
|
overviewDialog = ItemDetailsDialogInfo(item)
|
||||||
|
|
@ -245,24 +272,10 @@ fun SeriesDetails(
|
||||||
onClickDiscover = { index, item ->
|
onClickDiscover = { index, item ->
|
||||||
viewModel.navigateTo(item.destination)
|
viewModel.navigateTo(item.destination)
|
||||||
},
|
},
|
||||||
moreActions =
|
onShowContextMenu = {
|
||||||
MoreDialogActions(
|
showContextMenu = it
|
||||||
navigateTo = { viewModel.navigateTo(it) },
|
},
|
||||||
onClickWatch = { itemId, played ->
|
actions = contextActions,
|
||||||
viewModel.setWatched(itemId, played, null)
|
|
||||||
},
|
|
||||||
onClickFavorite = { itemId, played ->
|
|
||||||
viewModel.setFavorite(itemId, played, null)
|
|
||||||
},
|
|
||||||
onClickAddPlaylist = { itemId ->
|
|
||||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
|
||||||
showPlaylistDialog.makePresent(itemId)
|
|
||||||
},
|
|
||||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
|
||||||
onClickDelete = {
|
|
||||||
showDeleteDialog = it
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if (showWatchConfirmation) {
|
if (showWatchConfirmation) {
|
||||||
ConfirmDialog(
|
ConfirmDialog(
|
||||||
|
|
@ -281,6 +294,14 @@ fun SeriesDetails(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
showContextMenu?.let { contextMenu ->
|
||||||
|
ContextMenuDialog(
|
||||||
|
onDismissRequest = { showContextMenu = null },
|
||||||
|
getMediaSource = viewModel.streamChoiceService::chooseSource,
|
||||||
|
contextMenu = contextMenu,
|
||||||
|
preferredSubtitleLanguage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
overviewDialog?.let { info ->
|
overviewDialog?.let { info ->
|
||||||
ItemDetailsDialog(
|
ItemDetailsDialog(
|
||||||
info = info,
|
info = info,
|
||||||
|
|
@ -288,15 +309,6 @@ fun SeriesDetails(
|
||||||
onDismissRequest = { overviewDialog = null },
|
onDismissRequest = { overviewDialog = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
seasonDialog?.let { params ->
|
|
||||||
DialogPopup(
|
|
||||||
showDialog = true,
|
|
||||||
title = params.title,
|
|
||||||
dialogItems = params.items,
|
|
||||||
waitToLoad = params.fromLongClick,
|
|
||||||
onDismissRequest = { seasonDialog = null },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
showPlaylistDialog.compose { itemId ->
|
showPlaylistDialog.compose { itemId ->
|
||||||
PlaylistDialog(
|
PlaylistDialog(
|
||||||
title = stringResource(R.string.add_to_playlist),
|
title = stringResource(R.string.add_to_playlist),
|
||||||
|
|
@ -314,19 +326,6 @@ fun SeriesDetails(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showDeleteDialog?.let { item ->
|
|
||||||
ConfirmDeleteDialog(
|
|
||||||
itemTitle = item.title ?: "",
|
|
||||||
onCancel = { showDeleteDialog = null },
|
|
||||||
onConfirm = {
|
|
||||||
if (seasons?.lastOrNull()?.id == item.id) {
|
|
||||||
focusManager.moveFocus(FocusDirection.Previous)
|
|
||||||
}
|
|
||||||
viewModel.deleteItem(item)
|
|
||||||
showDeleteDialog = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val HEADER_ROW = 0
|
private const val HEADER_ROW = 0
|
||||||
|
|
@ -359,7 +358,8 @@ fun SeriesDetailsContent(
|
||||||
favoriteOnClick: () -> Unit,
|
favoriteOnClick: () -> Unit,
|
||||||
trailerOnClick: (Trailer) -> Unit,
|
trailerOnClick: (Trailer) -> Unit,
|
||||||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||||
moreActions: MoreDialogActions,
|
onShowContextMenu: (ContextMenu) -> Unit,
|
||||||
|
actions: ContextMenuActions,
|
||||||
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||||
discoverSeries: DiscoverItem?,
|
discoverSeries: DiscoverItem?,
|
||||||
onClickDiscoverSeries: () -> Unit,
|
onClickDiscoverSeries: () -> Unit,
|
||||||
|
|
@ -373,7 +373,6 @@ fun SeriesDetailsContent(
|
||||||
val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } }
|
val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } }
|
||||||
val playFocusRequester = remember { FocusRequester() }
|
val playFocusRequester = remember { FocusRequester() }
|
||||||
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
|
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -486,9 +485,10 @@ fun SeriesDetailsContent(
|
||||||
)
|
)
|
||||||
if (canDelete) {
|
if (canDelete) {
|
||||||
DeleteButton(
|
DeleteButton(
|
||||||
onClick = {
|
title = series.title ?: "",
|
||||||
|
onConfirmDelete = {
|
||||||
position = HEADER_ROW
|
position = HEADER_ROW
|
||||||
moreActions.onClickDelete.invoke(series)
|
actions.onDeleteItem.invoke(series)
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -561,18 +561,17 @@ fun SeriesDetailsContent(
|
||||||
},
|
},
|
||||||
onLongClick = { index, person ->
|
onLongClick = { index, person ->
|
||||||
position = PEOPLE_ROW
|
position = PEOPLE_ROW
|
||||||
val items =
|
onShowContextMenu.invoke(
|
||||||
buildMoreDialogItemsForPerson(
|
ContextMenu.ForPerson(
|
||||||
context = context,
|
|
||||||
person = person,
|
|
||||||
actions = moreActions,
|
|
||||||
)
|
|
||||||
moreDialog =
|
|
||||||
DialogParams(
|
|
||||||
fromLongClick = true,
|
fromLongClick = true,
|
||||||
title = person.name ?: "",
|
person = person,
|
||||||
items = items,
|
actions =
|
||||||
)
|
PersonContextActions(
|
||||||
|
navigateTo = actions.navigateTo,
|
||||||
|
onClickFavorite = actions.onClickFavorite,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -608,23 +607,19 @@ fun SeriesDetailsContent(
|
||||||
},
|
},
|
||||||
onLongClickItem = { index, item ->
|
onLongClickItem = { index, item ->
|
||||||
position = SIMILAR_ROW
|
position = SIMILAR_ROW
|
||||||
val items =
|
onShowContextMenu.invoke(
|
||||||
buildMoreDialogItemsForHome(
|
ContextMenu.ForBaseItem(
|
||||||
context = context,
|
|
||||||
item = item,
|
|
||||||
seriesId = null,
|
|
||||||
playbackPosition = item.playbackPosition,
|
|
||||||
watched = item.played,
|
|
||||||
favorite = item.favorite,
|
|
||||||
actions = moreActions,
|
|
||||||
canDelete = false,
|
|
||||||
)
|
|
||||||
moreDialog =
|
|
||||||
DialogParams(
|
|
||||||
fromLongClick = true,
|
fromLongClick = true,
|
||||||
title = item.name ?: "",
|
item = item,
|
||||||
items = items,
|
chosenStreams = null,
|
||||||
)
|
showGoTo = true,
|
||||||
|
showStreamChoices = false,
|
||||||
|
canDelete = false,
|
||||||
|
canRemoveContinueWatching = false,
|
||||||
|
canRemoveNextUp = false,
|
||||||
|
actions = actions,
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
cardContent = { index, item, mod, onClick, onLongClick ->
|
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||||
SeasonCard(
|
SeasonCard(
|
||||||
|
|
@ -666,16 +661,6 @@ fun SeriesDetailsContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
moreDialog?.let { params ->
|
|
||||||
DialogPopup(
|
|
||||||
showDialog = true,
|
|
||||||
title = params.title,
|
|
||||||
dialogItems = params.items,
|
|
||||||
onDismissRequest = { moreDialog = null },
|
|
||||||
dismissOnClick = true,
|
|
||||||
waitToLoad = params.fromLongClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
@ -20,41 +21,30 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import androidx.lifecycle.map
|
import androidx.lifecycle.map
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.chooseStream
|
|
||||||
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
|
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.api.PersonKind
|
import org.jellyfin.sdk.model.api.PersonKind
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -98,16 +88,19 @@ fun SeriesOverview(
|
||||||
val episodeRowFocusRequester = remember { FocusRequester() }
|
val episodeRowFocusRequester = remember { FocusRequester() }
|
||||||
val castCrewRowFocusRequester = remember { FocusRequester() }
|
val castCrewRowFocusRequester = remember { FocusRequester() }
|
||||||
val guestStarRowFocusRequester = remember { FocusRequester() }
|
val guestStarRowFocusRequester = remember { FocusRequester() }
|
||||||
|
val extrasRowFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
|
|
||||||
val series by viewModel.item.observeAsState(null)
|
val series by viewModel.item.observeAsState(null)
|
||||||
val seasons by viewModel.seasons.observeAsState(listOf())
|
val seasons by viewModel.seasons.observeAsState(emptyList())
|
||||||
val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
|
val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
|
||||||
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf())
|
val seasonExtras by viewModel.extras.observeAsState(emptyList())
|
||||||
|
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(emptyList())
|
||||||
val episodeList = (episodes as? EpisodeList.Success)?.episodes
|
val episodeList = (episodes as? EpisodeList.Success)?.episodes
|
||||||
|
|
||||||
val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
|
val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
|
||||||
|
val currentPosition by rememberUpdatedState(position)
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (seasons.isNotEmpty()) {
|
if (seasons.isNotEmpty()) {
|
||||||
seasons.getOrNull(position.seasonTabIndex)?.let {
|
seasons.getOrNull(position.seasonTabIndex)?.let {
|
||||||
|
|
@ -117,13 +110,54 @@ fun SeriesOverview(
|
||||||
}
|
}
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
|
||||||
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
|
||||||
var rowFocused by rememberInt()
|
var rowFocused by rememberInt()
|
||||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
|
||||||
|
val contextActions =
|
||||||
|
remember {
|
||||||
|
ContextMenuActions(
|
||||||
|
navigateTo = viewModel::navigateTo,
|
||||||
|
onClickWatch = { itemId, watched ->
|
||||||
|
viewModel.setWatched(itemId, watched, currentPosition.episodeRowIndex)
|
||||||
|
},
|
||||||
|
onClickFavorite = { itemId, favorite ->
|
||||||
|
viewModel.setFavorite(itemId, favorite, currentPosition.episodeRowIndex)
|
||||||
|
},
|
||||||
|
onClickAddPlaylist = { itemId ->
|
||||||
|
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
|
showPlaylistDialog = itemId
|
||||||
|
},
|
||||||
|
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||||
|
onDeleteItem = viewModel::deleteItem,
|
||||||
|
onChooseVersion = { item, source ->
|
||||||
|
viewModel.savePlayVersion(
|
||||||
|
item,
|
||||||
|
source.id!!.toUUID(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onChooseTracks = { result ->
|
||||||
|
viewModel.saveTrackSelection(
|
||||||
|
result.item,
|
||||||
|
result.itemPlayback,
|
||||||
|
result.trackIndex,
|
||||||
|
result.streamType,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||||
|
onClearChosenStreams = {
|
||||||
|
val focusedEpisode =
|
||||||
|
(episodes as? EpisodeList.Success)
|
||||||
|
?.episodes
|
||||||
|
?.getOrNull(currentPosition.episodeRowIndex)
|
||||||
|
if (focusedEpisode != null) {
|
||||||
|
viewModel.clearChosenStreams(focusedEpisode, it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(position, episodes) {
|
LaunchedEffect(position, episodes) {
|
||||||
val focusedEpisode =
|
val focusedEpisode =
|
||||||
|
|
@ -164,6 +198,7 @@ fun SeriesOverview(
|
||||||
EPISODE_ROW -> episodeRowFocusRequester
|
EPISODE_ROW -> episodeRowFocusRequester
|
||||||
CAST_AND_CREW_ROW -> castCrewRowFocusRequester
|
CAST_AND_CREW_ROW -> castCrewRowFocusRequester
|
||||||
GUEST_STAR_ROW -> guestStarRowFocusRequester
|
GUEST_STAR_ROW -> guestStarRowFocusRequester
|
||||||
|
EXTRAS_ROW -> extrasRowFocusRequester
|
||||||
else -> episodeRowFocusRequester
|
else -> episodeRowFocusRequester
|
||||||
},
|
},
|
||||||
"series_overview",
|
"series_overview",
|
||||||
|
|
@ -176,103 +211,6 @@ fun SeriesOverview(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun buildMoreForEpisode(
|
|
||||||
ep: BaseItem,
|
|
||||||
chosenStreams: ChosenStreams?,
|
|
||||||
fromLongClick: Boolean,
|
|
||||||
): DialogParams =
|
|
||||||
DialogParams(
|
|
||||||
fromLongClick = fromLongClick,
|
|
||||||
title = series.name + " - " + ep.data.seasonEpisode,
|
|
||||||
items =
|
|
||||||
buildMoreDialogItems(
|
|
||||||
context = context,
|
|
||||||
item = ep,
|
|
||||||
watched = ep.data.userData?.played ?: false,
|
|
||||||
favorite = ep.data.userData?.isFavorite ?: false,
|
|
||||||
seriesId = series.id,
|
|
||||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
|
||||||
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
|
||||||
canDelete = viewModel.canDelete(ep),
|
|
||||||
actions =
|
|
||||||
MoreDialogActions(
|
|
||||||
navigateTo = viewModel::navigateTo,
|
|
||||||
onClickWatch = { itemId, watched ->
|
|
||||||
viewModel.setWatched(
|
|
||||||
itemId,
|
|
||||||
watched,
|
|
||||||
position.episodeRowIndex,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClickFavorite = { itemId, favorite ->
|
|
||||||
viewModel.setFavorite(
|
|
||||||
itemId,
|
|
||||||
favorite,
|
|
||||||
position.episodeRowIndex,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClickAddPlaylist = {
|
|
||||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
|
||||||
showPlaylistDialog = it
|
|
||||||
},
|
|
||||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
|
||||||
onClickDelete = {
|
|
||||||
showDeleteDialog = it
|
|
||||||
},
|
|
||||||
),
|
|
||||||
onChooseVersion = {
|
|
||||||
chooseVersion =
|
|
||||||
chooseVersionParams(
|
|
||||||
context,
|
|
||||||
ep.data.mediaSources!!,
|
|
||||||
chosenStreams?.source?.id?.toUUIDOrNull(),
|
|
||||||
) { idx ->
|
|
||||||
val source = ep.data.mediaSources!![idx]
|
|
||||||
viewModel.savePlayVersion(
|
|
||||||
ep,
|
|
||||||
source.id!!.toUUID(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
moreDialog = null
|
|
||||||
},
|
|
||||||
onChooseTracks = { type ->
|
|
||||||
viewModel.streamChoiceService
|
|
||||||
.chooseSource(
|
|
||||||
ep.data,
|
|
||||||
chosenStreams?.itemPlayback,
|
|
||||||
)?.let { source ->
|
|
||||||
chooseVersion =
|
|
||||||
chooseStream(
|
|
||||||
context = context,
|
|
||||||
streams = source.mediaStreams.orEmpty(),
|
|
||||||
type = type,
|
|
||||||
currentIndex =
|
|
||||||
if (type == MediaStreamType.AUDIO) {
|
|
||||||
chosenStreams?.audioStream?.index
|
|
||||||
} else {
|
|
||||||
chosenStreams?.subtitleStream?.index
|
|
||||||
},
|
|
||||||
onClick = { trackIndex ->
|
|
||||||
viewModel.saveTrackSelection(
|
|
||||||
ep,
|
|
||||||
chosenStreams?.itemPlayback,
|
|
||||||
trackIndex,
|
|
||||||
type,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onShowOverview = {
|
|
||||||
overviewDialog = ItemDetailsDialogInfo(ep)
|
|
||||||
},
|
|
||||||
onClearChosenStreams = {
|
|
||||||
viewModel.clearChosenStreams(ep, chosenStreams)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
SeriesOverviewContent(
|
SeriesOverviewContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
series = series,
|
series = series,
|
||||||
|
|
@ -280,11 +218,13 @@ fun SeriesOverview(
|
||||||
episodes = episodes,
|
episodes = episodes,
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = chosenStreams,
|
||||||
peopleInEpisode = peopleInEpisode,
|
peopleInEpisode = peopleInEpisode,
|
||||||
|
seasonExtras = seasonExtras,
|
||||||
position = position,
|
position = position,
|
||||||
firstItemFocusRequester = firstItemFocusRequester,
|
firstItemFocusRequester = firstItemFocusRequester,
|
||||||
episodeRowFocusRequester = episodeRowFocusRequester,
|
episodeRowFocusRequester = episodeRowFocusRequester,
|
||||||
castCrewRowFocusRequester = castCrewRowFocusRequester,
|
castCrewRowFocusRequester = castCrewRowFocusRequester,
|
||||||
guestStarRowFocusRequester = guestStarRowFocusRequester,
|
guestStarRowFocusRequester = guestStarRowFocusRequester,
|
||||||
|
extrasRowFocusRequester = extrasRowFocusRequester,
|
||||||
onChangeSeason = { index ->
|
onChangeSeason = { index ->
|
||||||
if (index != position.seasonTabIndex) {
|
if (index != position.seasonTabIndex) {
|
||||||
seasons.getOrNull(index)?.let { season ->
|
seasons.getOrNull(index)?.let { season ->
|
||||||
|
|
@ -314,9 +254,18 @@ fun SeriesOverview(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onLongClick = { ep ->
|
onLongClick = { ep ->
|
||||||
scope.launchDefault {
|
showContextMenu =
|
||||||
moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
|
ContextMenu.ForBaseItem(
|
||||||
}
|
fromLongClick = true,
|
||||||
|
item = ep,
|
||||||
|
chosenStreams = chosenStreams,
|
||||||
|
showGoTo = false,
|
||||||
|
showStreamChoices = true,
|
||||||
|
canDelete = viewModel.canDelete(ep, preferences.appPreferences),
|
||||||
|
canRemoveContinueWatching = false,
|
||||||
|
canRemoveNextUp = false,
|
||||||
|
actions = contextActions,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
playOnClick = { resume ->
|
playOnClick = { resume ->
|
||||||
rowFocused = EPISODE_ROW
|
rowFocused = EPISODE_ROW
|
||||||
|
|
@ -344,9 +293,18 @@ fun SeriesOverview(
|
||||||
},
|
},
|
||||||
moreOnClick = {
|
moreOnClick = {
|
||||||
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
|
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
|
||||||
scope.launchDefault {
|
showContextMenu =
|
||||||
moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
|
ContextMenu.ForBaseItem(
|
||||||
}
|
fromLongClick = false,
|
||||||
|
item = ep,
|
||||||
|
chosenStreams = chosenStreams,
|
||||||
|
showGoTo = false,
|
||||||
|
showStreamChoices = true,
|
||||||
|
canDelete = viewModel.canDelete(ep, preferences.appPreferences),
|
||||||
|
canRemoveContinueWatching = false,
|
||||||
|
canRemoveNextUp = false,
|
||||||
|
actions = contextActions,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
overviewOnClick = {
|
overviewOnClick = {
|
||||||
|
|
@ -364,14 +322,25 @@ fun SeriesOverview(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onClickExtra = { _, extra ->
|
||||||
|
rowFocused = EXTRAS_ROW
|
||||||
|
viewModel.navigateTo(extra.destination)
|
||||||
|
},
|
||||||
canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
|
canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
|
||||||
deleteOnClick = { showDeleteDialog = it },
|
onConfirmDelete = viewModel::deleteItem,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
showContextMenu?.let { contextMenu ->
|
||||||
|
ContextMenuDialog(
|
||||||
|
onDismissRequest = { showContextMenu = null },
|
||||||
|
getMediaSource = viewModel.streamChoiceService::chooseSource,
|
||||||
|
contextMenu = contextMenu,
|
||||||
|
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||||
|
)
|
||||||
|
}
|
||||||
overviewDialog?.let { info ->
|
overviewDialog?.let { info ->
|
||||||
ItemDetailsDialog(
|
ItemDetailsDialog(
|
||||||
info = info,
|
info = info,
|
||||||
|
|
@ -382,26 +351,6 @@ fun SeriesOverview(
|
||||||
onDismissRequest = { overviewDialog = null },
|
onDismissRequest = { overviewDialog = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
moreDialog?.let { params ->
|
|
||||||
DialogPopup(
|
|
||||||
showDialog = true,
|
|
||||||
title = params.title,
|
|
||||||
dialogItems = params.items,
|
|
||||||
onDismissRequest = { moreDialog = null },
|
|
||||||
dismissOnClick = true,
|
|
||||||
waitToLoad = params.fromLongClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
chooseVersion?.let { params ->
|
|
||||||
DialogPopup(
|
|
||||||
showDialog = true,
|
|
||||||
title = params.title,
|
|
||||||
dialogItems = params.items,
|
|
||||||
onDismissRequest = { chooseVersion = null },
|
|
||||||
dismissOnClick = true,
|
|
||||||
waitToLoad = params.fromLongClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
showPlaylistDialog?.let { itemId ->
|
showPlaylistDialog?.let { itemId ->
|
||||||
PlaylistDialog(
|
PlaylistDialog(
|
||||||
title = stringResource(R.string.add_to_playlist),
|
title = stringResource(R.string.add_to_playlist),
|
||||||
|
|
@ -419,19 +368,9 @@ fun SeriesOverview(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showDeleteDialog?.let { item ->
|
|
||||||
ConfirmDeleteDialog(
|
|
||||||
itemTitle = item.subtitle ?: "",
|
|
||||||
onCancel = { showDeleteDialog = null },
|
|
||||||
onConfirm = {
|
|
||||||
viewModel.deleteItem(item)
|
|
||||||
episodeRowFocusRequester.tryRequestFocus()
|
|
||||||
showDeleteDialog = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val EPISODE_ROW = 0
|
private const val EPISODE_ROW = 0
|
||||||
private const val CAST_AND_CREW_ROW = EPISODE_ROW + 1
|
private const val CAST_AND_CREW_ROW = EPISODE_ROW + 1
|
||||||
private const val GUEST_STAR_ROW = CAST_AND_CREW_ROW + 1
|
private const val GUEST_STAR_ROW = CAST_AND_CREW_ROW + 1
|
||||||
|
private const val EXTRAS_ROW = GUEST_STAR_ROW + 1
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
|
@ -47,11 +46,13 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
|
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
|
|
@ -74,6 +75,7 @@ fun SeriesOverviewContent(
|
||||||
series: BaseItem,
|
series: BaseItem,
|
||||||
seasons: List<BaseItem?>,
|
seasons: List<BaseItem?>,
|
||||||
episodes: EpisodeList,
|
episodes: EpisodeList,
|
||||||
|
seasonExtras: List<ExtrasItem>,
|
||||||
chosenStreams: ChosenStreams?,
|
chosenStreams: ChosenStreams?,
|
||||||
peopleInEpisode: List<Person>,
|
peopleInEpisode: List<Person>,
|
||||||
position: SeriesOverviewPosition,
|
position: SeriesOverviewPosition,
|
||||||
|
|
@ -81,6 +83,7 @@ fun SeriesOverviewContent(
|
||||||
episodeRowFocusRequester: FocusRequester,
|
episodeRowFocusRequester: FocusRequester,
|
||||||
castCrewRowFocusRequester: FocusRequester,
|
castCrewRowFocusRequester: FocusRequester,
|
||||||
guestStarRowFocusRequester: FocusRequester,
|
guestStarRowFocusRequester: FocusRequester,
|
||||||
|
extrasRowFocusRequester: FocusRequester,
|
||||||
onChangeSeason: (Int) -> Unit,
|
onChangeSeason: (Int) -> Unit,
|
||||||
onFocusEpisode: (Int) -> Unit,
|
onFocusEpisode: (Int) -> Unit,
|
||||||
onClick: (BaseItem) -> Unit,
|
onClick: (BaseItem) -> Unit,
|
||||||
|
|
@ -92,7 +95,8 @@ fun SeriesOverviewContent(
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
personOnClick: (Person) -> Unit,
|
personOnClick: (Person) -> Unit,
|
||||||
canDelete: (BaseItem) -> Boolean,
|
canDelete: (BaseItem) -> Boolean,
|
||||||
deleteOnClick: (BaseItem) -> Unit,
|
onConfirmDelete: (BaseItem) -> Unit,
|
||||||
|
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
@ -318,7 +322,7 @@ fun SeriesOverviewContent(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
canDelete = canDelete.invoke(ep),
|
canDelete = canDelete.invoke(ep),
|
||||||
deleteOnClick = { deleteOnClick.invoke(ep) },
|
onConfirmDelete = { onConfirmDelete.invoke(ep) },
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 4.dp)
|
.padding(top = 4.dp)
|
||||||
|
|
@ -365,6 +369,21 @@ fun SeriesOverviewContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = seasonExtras.isNotEmpty(),
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
ExtrasRow(
|
||||||
|
extras = seasonExtras,
|
||||||
|
onClickItem = onClickExtra,
|
||||||
|
onLongClickItem = { _, _ -> },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(extrasRowFocusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
@ -138,7 +139,11 @@ class SeriesViewModel
|
||||||
Timber.v("Start")
|
Timber.v("Start")
|
||||||
addCloseable { themeSongPlayer.stop() }
|
addCloseable { themeSongPlayer.stop() }
|
||||||
val item = fetchItem(seriesId)
|
val item = fetchItem(seriesId)
|
||||||
canDeleteSeries.update { mediaManagementService.canDelete(item) }
|
viewModelScope.launchDefault {
|
||||||
|
mediaManagementService.collectCanDelete(flowOf(item)) { canDelete ->
|
||||||
|
canDeleteSeries.update { canDelete }
|
||||||
|
}
|
||||||
|
}
|
||||||
backdropService.submit(item)
|
backdropService.submit(item)
|
||||||
|
|
||||||
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
|
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
|
||||||
|
|
@ -184,6 +189,10 @@ class SeriesViewModel
|
||||||
it.copy(seasonTabIndex = index.coerceAtLeast(0))
|
it.copy(seasonTabIndex = index.coerceAtLeast(0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val extras = extrasService.getExtras(seasonEpisodeIds.seasonId)
|
||||||
|
this@SeriesViewModel.extras.setValueOnMain(extras)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val remoteTrailers = trailerService.getRemoteTrailers(item)
|
val remoteTrailers = trailerService.getRemoteTrailers(item)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|
@ -388,6 +397,7 @@ class SeriesViewModel
|
||||||
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
|
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
|
||||||
this@SeriesViewModel.peopleInEpisode.value = PeopleInItem()
|
this@SeriesViewModel.peopleInEpisode.value = PeopleInItem()
|
||||||
this@SeriesViewModel.episodes.value = EpisodeList.Loading
|
this@SeriesViewModel.episodes.value = EpisodeList.Loading
|
||||||
|
this@SeriesViewModel.extras.value = emptyList()
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO(ExceptionHandler(true)) {
|
viewModelScope.launchIO(ExceptionHandler(true)) {
|
||||||
val episodes =
|
val episodes =
|
||||||
|
|
@ -401,6 +411,10 @@ class SeriesViewModel
|
||||||
this@SeriesViewModel.episodes.value = episodes
|
this@SeriesViewModel.episodes.value = episodes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val extras = extrasService.getExtras(seasonId)
|
||||||
|
this@SeriesViewModel.extras.setValueOnMain(extras)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setWatched(
|
fun setWatched(
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ fun DiscoverRequestGrid(
|
||||||
gridFocusRequester = gridFocusRequester,
|
gridFocusRequester = gridFocusRequester,
|
||||||
showJumpButtons = false,
|
showJumpButtons = false,
|
||||||
showLetterButtons = false,
|
showLetterButtons = false,
|
||||||
cardContent = { item, onClick, onLongClick, mod ->
|
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||||
DiscoverItemCard(
|
DiscoverItemCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ fun SeerrRequestsPage(
|
||||||
showJumpButtons = false,
|
showJumpButtons = false,
|
||||||
showLetterButtons = false,
|
showLetterButtons = false,
|
||||||
spacing = 16.dp,
|
spacing = 16.dp,
|
||||||
cardContent = @Composable { item, onClick, onLongClick, mod ->
|
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||||
DiscoverItemCard(
|
DiscoverItemCard(
|
||||||
item = item?.item,
|
item = item?.item,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,11 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
|
@ -56,24 +58,23 @@ import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.StudioCard
|
import com.github.damontecres.wholphin.ui.cards.StudioCard
|
||||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.FocusableItemRow
|
import com.github.damontecres.wholphin.ui.components.FocusableItemRow
|
||||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.components.RowColumnItem
|
|
||||||
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||||
import com.github.damontecres.wholphin.ui.components.rememberLogoUrl
|
import com.github.damontecres.wholphin.ui.components.rememberLogoUrl
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
|
||||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
|
@ -120,9 +121,10 @@ fun HomePage(
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
var dialog by remember { mutableStateOf<DialogParams?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
||||||
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
|
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
var position by rememberPosition()
|
var position by rememberPosition()
|
||||||
|
|
||||||
|
|
@ -144,43 +146,46 @@ fun HomePage(
|
||||||
row?.rowType is HomeRowConfig.ContinueWatching || row?.rowType is HomeRowConfig.ContinueWatchingCombined
|
row?.rowType is HomeRowConfig.ContinueWatching || row?.rowType is HomeRowConfig.ContinueWatchingCombined
|
||||||
val canRemoveNextUp =
|
val canRemoveNextUp =
|
||||||
row?.rowType is HomeRowConfig.NextUp || row?.rowType is HomeRowConfig.ContinueWatchingCombined
|
row?.rowType is HomeRowConfig.NextUp || row?.rowType is HomeRowConfig.ContinueWatchingCombined
|
||||||
val dialogItems =
|
showContextMenu =
|
||||||
buildMoreDialogItemsForHome(
|
ContextMenu.ForBaseItem(
|
||||||
context = context,
|
fromLongClick = true,
|
||||||
item = item,
|
item = item,
|
||||||
seriesId = item.data.seriesId,
|
chosenStreams = null,
|
||||||
playbackPosition = item.playbackPosition,
|
showGoTo = true,
|
||||||
watched = item.played,
|
showStreamChoices = false,
|
||||||
favorite = item.favorite,
|
canDelete =
|
||||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
viewModel.canDelete(
|
||||||
canRemoveNextUp = canRemoveNextUp,
|
item,
|
||||||
|
preferences.appPreferences,
|
||||||
|
),
|
||||||
canRemoveContinueWatching = canRemoveContinueWatching,
|
canRemoveContinueWatching = canRemoveContinueWatching,
|
||||||
|
canRemoveNextUp = canRemoveNextUp,
|
||||||
actions =
|
actions =
|
||||||
MoreDialogActions(
|
ContextMenuActions(
|
||||||
navigateTo = viewModel.navigationManager::navigateTo,
|
navigateTo = viewModel.navigationManager::navigateTo,
|
||||||
onClickWatch = { itemId, played ->
|
onClickWatch = viewModel::setWatched,
|
||||||
viewModel.setWatched(itemId, played)
|
onClickFavorite = viewModel::setFavorite,
|
||||||
},
|
|
||||||
onClickFavorite = { itemId, favorite ->
|
|
||||||
viewModel.setFavorite(itemId, favorite)
|
|
||||||
},
|
|
||||||
onClickAddPlaylist = { itemId ->
|
onClickAddPlaylist = { itemId ->
|
||||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
showPlaylistDialog = itemId
|
showPlaylistDialog = itemId
|
||||||
},
|
},
|
||||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||||
onClickDelete = {
|
onDeleteItem = {
|
||||||
showDeleteDialog = RowColumnItem(position, item)
|
viewModel.deleteItem(position, it)
|
||||||
},
|
},
|
||||||
|
onChooseVersion = { _, _ ->
|
||||||
|
// Not supported on this page
|
||||||
|
},
|
||||||
|
onChooseTracks = {
|
||||||
|
// Not supported on this page
|
||||||
|
},
|
||||||
|
onShowOverview = {
|
||||||
|
overviewDialog = ItemDetailsDialogInfo(it)
|
||||||
|
},
|
||||||
|
onClearChosenStreams = {},
|
||||||
onClickRemoveFromNextUp = viewModel::removeFromNextUp,
|
onClickRemoveFromNextUp = viewModel::removeFromNextUp,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
dialog =
|
|
||||||
DialogParams(
|
|
||||||
title = item.title ?: "",
|
|
||||||
fromLongClick = true,
|
|
||||||
items = dialogItems,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val onClickPlay =
|
val onClickPlay =
|
||||||
|
|
@ -203,10 +208,19 @@ fun HomePage(
|
||||||
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
dialog?.let { params ->
|
overviewDialog?.let { info ->
|
||||||
DialogPopup(
|
ItemDetailsDialog(
|
||||||
params = params,
|
info = info,
|
||||||
onDismissRequest = { dialog = null },
|
showFilePath = false,
|
||||||
|
onDismissRequest = { overviewDialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showContextMenu?.let { contextMenu ->
|
||||||
|
ContextMenuDialog(
|
||||||
|
onDismissRequest = { showContextMenu = null },
|
||||||
|
getMediaSource = null,
|
||||||
|
contextMenu = contextMenu,
|
||||||
|
preferredSubtitleLanguage = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showPlaylistDialog?.let { itemId ->
|
showPlaylistDialog?.let { itemId ->
|
||||||
|
|
@ -226,19 +240,7 @@ fun HomePage(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showDeleteDialog?.let { (position, item) ->
|
|
||||||
ConfirmDeleteDialog(
|
|
||||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
|
||||||
onCancel = { showDeleteDialog = null },
|
|
||||||
onConfirm = {
|
|
||||||
viewModel.deleteItem(position, item)
|
|
||||||
showDeleteDialog = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,7 +308,15 @@ fun HomePageContent(
|
||||||
focusedItem?.let { onUpdateBackdrop.invoke(it) }
|
focusedItem?.let { onUpdateBackdrop.invoke(it) }
|
||||||
}
|
}
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusProperties {
|
||||||
|
onEnter = {
|
||||||
|
rowFocusRequesters.getOrNull(currentPosition.row)?.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}.fillMaxSize(),
|
||||||
|
) {
|
||||||
headerComposable.invoke(focusedItem)
|
headerComposable.invoke(focusedItem)
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
@ -406,7 +416,7 @@ fun HomePageContent(
|
||||||
}
|
}
|
||||||
val onKey =
|
val onKey =
|
||||||
remember(item) {
|
remember(item) {
|
||||||
{ event: androidx.compose.ui.input.key.KeyEvent ->
|
{ event: KeyEvent ->
|
||||||
if (isPlayKeyUp(event) && item?.type?.playable == true) {
|
if (isPlayKeyUp(event) && item?.type?.playable == true) {
|
||||||
Timber.v("Clicked play on ${item.id}")
|
Timber.v("Clicked play on ${item.id}")
|
||||||
currentOnClickPlay(
|
currentOnClickPlay(
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.selects.select
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -87,26 +88,30 @@ class HomeViewModel
|
||||||
// Refreshing if a load has already occurred and the rows haven't significantly changed
|
// Refreshing if a load has already occurred and the rows haven't significantly changed
|
||||||
val refresh =
|
val refresh =
|
||||||
state.loadingState == LoadingState.Success && state.settings == settings
|
state.loadingState == LoadingState.Success && state.settings == settings
|
||||||
Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}")
|
Timber.v(
|
||||||
|
"refresh=%s, state.loadingState=%s, %s rows",
|
||||||
|
refresh,
|
||||||
|
state.loadingState,
|
||||||
|
settings.rows.size,
|
||||||
|
)
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
loadingState = if (refresh) LoadingState.Success else LoadingState.Loading,
|
loadingState = if (refresh) LoadingState.Success else LoadingState.Loading,
|
||||||
refreshState = LoadingState.Loading,
|
refreshState = LoadingState.Loading,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
|
homeRows =
|
||||||
|
if (refresh) {
|
||||||
|
it.homeRows
|
||||||
|
} else {
|
||||||
|
List(settings.rows.size) { HomeRowLoadingState.Pending("") }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val semaphore = Semaphore(4)
|
val semaphore = Semaphore(4)
|
||||||
|
|
||||||
val watchingRowIndexes =
|
|
||||||
settings.rows
|
|
||||||
.mapIndexedNotNull { index, row ->
|
|
||||||
if (isWatchingRow(row.config)) index else null
|
|
||||||
}
|
|
||||||
val deferred =
|
val deferred =
|
||||||
settings.rows
|
settings.rows
|
||||||
// Load the watching rows first
|
|
||||||
.sortedByDescending { isWatchingRow(it.config) }
|
|
||||||
.map { row ->
|
.map { row ->
|
||||||
viewModelScope.async(Dispatchers.IO) {
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
|
|
@ -123,53 +128,63 @@ class HomeViewModel
|
||||||
)
|
)
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Error on row %s", row)
|
Timber.e(ex, "Error on row %s", row)
|
||||||
HomeRowLoadingState.Error(row.title, exception = ex)
|
HomeRowLoadingState.Error(
|
||||||
|
row.title,
|
||||||
|
exception = ex,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (refresh && state.homeRows.isNotEmpty() && watchingRowIndexes.isNotEmpty()) {
|
if (refresh) {
|
||||||
// Replace watching rows first
|
// Replace rows as they complete
|
||||||
Timber.v("Refreshing rows: %s", watchingRowIndexes)
|
val remaining = deferred.withIndex().toMutableList()
|
||||||
val rows =
|
while (remaining.isNotEmpty()) {
|
||||||
deferred
|
val (rowIndex, rowData) =
|
||||||
.filterIndexed { index, _ -> index in watchingRowIndexes }
|
select {
|
||||||
.awaitAll()
|
// "Return" the first remaining that is completed
|
||||||
_state.update {
|
remaining
|
||||||
val newRows =
|
.forEach { (rowIndex, deferred) ->
|
||||||
it.homeRows.toMutableList().apply {
|
deferred.onAwait { rowIndex to it }
|
||||||
rows.forEachIndexed { index, row ->
|
}
|
||||||
set(watchingRowIndexes[index], row)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Timber.v("Got row data index=%s", rowIndex)
|
||||||
|
remaining.removeIf { it.index == rowIndex }
|
||||||
|
_state.update { state ->
|
||||||
|
val newRows =
|
||||||
|
state.homeRows.toMutableList().apply {
|
||||||
|
set(rowIndex, rowData)
|
||||||
|
}
|
||||||
|
state.copy(
|
||||||
|
homeRows = newRows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
loadingState = LoadingState.Success,
|
loadingState = LoadingState.Success,
|
||||||
homeRows = newRows,
|
refreshState = LoadingState.Success,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val rows = deferred.awaitAll()
|
||||||
|
Timber.v("Got all rows")
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
loadingState = LoadingState.Success,
|
||||||
|
refreshState = LoadingState.Success,
|
||||||
|
homeRows = rows,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val rows =
|
Timber.d("Home page load complete")
|
||||||
deferred
|
|
||||||
.awaitAll()
|
|
||||||
.filter {
|
|
||||||
// Include only errors & non-empty successes
|
|
||||||
it is HomeRowLoadingState.Error ||
|
|
||||||
(it is HomeRowLoadingState.Success && it.items.isNotEmpty())
|
|
||||||
}
|
|
||||||
Timber.v("Got all rows")
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
loadingState = LoadingState.Success,
|
|
||||||
refreshState = LoadingState.Success,
|
|
||||||
homeRows = rows,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Exception during home page loading")
|
Timber.e(ex, "Exception during home page loading")
|
||||||
if (state.value.loadingState == LoadingState.Success) {
|
if (state.value.loadingState == LoadingState.Success) {
|
||||||
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
|
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
|
||||||
|
_state.update { it.copy(refreshState = LoadingState.Error(ex)) }
|
||||||
} else {
|
} else {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(loadingState = LoadingState.Error(ex))
|
it.copy(loadingState = LoadingState.Error(ex))
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||||
|
|
@ -78,8 +78,8 @@ sealed class Destination(
|
||||||
data class Playback(
|
data class Playback(
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
val itemPlayback: ItemPlayback? = null,
|
|
||||||
val forceTranscoding: Boolean = false,
|
val forceTranscoding: Boolean = false,
|
||||||
|
val backend: PlayerBackend? = null,
|
||||||
) : Destination(true) {
|
) : Destination(true) {
|
||||||
constructor(item: BaseItem) : this(item.id, item.resumeMs)
|
constructor(item: BaseItem) : this(item.id, item.resumeMs)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,25 @@ fun DestinationContent(
|
||||||
HomeSettingsPage(preferences, modifier)
|
HomeSettingsPage(preferences, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
is Destination.PlaybackList,
|
is Destination.Playback -> {
|
||||||
is Destination.Playback,
|
val backend =
|
||||||
-> {
|
destination.backend ?: preferences.appPreferences.playbackPreferences.playerBackend
|
||||||
|
if (backend == PlayerBackend.EXTERNAL_PLAYER) {
|
||||||
|
PlayExternalPage(
|
||||||
|
preferences = preferences,
|
||||||
|
destination = destination,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PlaybackPage(
|
||||||
|
preferences = preferences,
|
||||||
|
destination = destination,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Destination.PlaybackList -> {
|
||||||
if (preferences.appPreferences.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER) {
|
if (preferences.appPreferences.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER) {
|
||||||
PlayExternalPage(
|
PlayExternalPage(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,13 @@ import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.ActivityResult
|
import androidx.activity.result.ActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
|
@ -25,6 +24,7 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||||
|
|
@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.findActivity
|
||||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
|
|
@ -40,9 +41,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers
|
import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import dagger.assisted.Assisted
|
|
||||||
import dagger.assisted.AssistedFactory
|
|
||||||
import dagger.assisted.AssistedInject
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
@ -54,17 +52,22 @@ import org.jellyfin.sdk.api.client.extensions.subtitleApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
import org.jellyfin.sdk.model.api.PlaybackOrder
|
||||||
|
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||||
|
import org.jellyfin.sdk.model.api.RepeatMode
|
||||||
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@HiltViewModel(assistedFactory = PlayExternalViewModel.Factory::class)
|
@HiltViewModel
|
||||||
class PlayExternalViewModel
|
class PlayExternalViewModel
|
||||||
@AssistedInject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val savedStateHandle: SavedStateHandle,
|
private val savedStateHandle: SavedStateHandle,
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
|
|
@ -75,16 +78,13 @@ class PlayExternalViewModel
|
||||||
private val streamChoiceService: StreamChoiceService,
|
private val streamChoiceService: StreamChoiceService,
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
@Assisted val destination: Destination,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@AssistedFactory
|
val launched = savedStateHandle.getMutableStateFlow("launched", false)
|
||||||
interface Factory {
|
|
||||||
fun create(destination: Destination): PlayExternalViewModel
|
|
||||||
}
|
|
||||||
|
|
||||||
val state = MutableStateFlow(PlayExternalState())
|
val state = MutableStateFlow(PlayExternalState())
|
||||||
|
|
||||||
fun init() {
|
fun init(destination: Destination) {
|
||||||
|
Timber.v("init called: %s", destination)
|
||||||
|
state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val positionMs: Long
|
val positionMs: Long
|
||||||
|
|
@ -106,9 +106,9 @@ class PlayExternalViewModel
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val queriedItem = api.userLibraryApi.getItem(itemId).content
|
val queriedItem = api.userLibraryApi.getItem(itemId).content
|
||||||
val base =
|
val playlistItem =
|
||||||
if (queriedItem.type.playable) {
|
if (queriedItem.type.playable) {
|
||||||
queriedItem
|
PlaylistItem.Media(BaseItem(queriedItem))
|
||||||
} else if (destination is Destination.PlaybackList) {
|
} else if (destination is Destination.PlaybackList) {
|
||||||
val playlistResult =
|
val playlistResult =
|
||||||
playlistCreator.createFrom(
|
playlistCreator.createFrom(
|
||||||
|
|
@ -135,18 +135,15 @@ class PlayExternalViewModel
|
||||||
navigationManager.goBack()
|
navigationManager.goBack()
|
||||||
return@launchDefault
|
return@launchDefault
|
||||||
}
|
}
|
||||||
r.playlist.items
|
r.playlist.items.first()
|
||||||
.first()
|
|
||||||
.data
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
|
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
|
||||||
}
|
}
|
||||||
val item = BaseItem(base, false)
|
|
||||||
val playbackConfig =
|
val playbackConfig =
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser.value?.let { user ->
|
||||||
itemPlaybackDao.getItem(user, base.id)?.let {
|
itemPlaybackDao.getItem(user, playlistItem.id)?.let {
|
||||||
Timber.v("Fetched itemPlayback from DB: %s", it)
|
Timber.v("Fetched itemPlayback from DB: %s", it)
|
||||||
if (it.sourceId != null) {
|
if (it.sourceId != null) {
|
||||||
it
|
it
|
||||||
|
|
@ -155,20 +152,25 @@ class PlayExternalViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val mediaSource = streamChoiceService.chooseSource(base, playbackConfig)
|
val item =
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
|
when (playlistItem) {
|
||||||
|
is PlaylistItem.Intro -> playlistItem.item
|
||||||
|
is PlaylistItem.Media -> playlistItem.item
|
||||||
|
}
|
||||||
|
val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig)
|
||||||
|
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||||
if (mediaSource == null) {
|
if (mediaSource == null) {
|
||||||
Timber.w("Media source is null")
|
Timber.w("Media source is null")
|
||||||
return@launchDefault
|
return@launchDefault
|
||||||
}
|
}
|
||||||
savedStateHandle[KEY_ID] = base.id
|
savedStateHandle[KEY_ID] = playlistItem.id
|
||||||
savedStateHandle[KEY_MEDIA_ID] = mediaSource.id
|
savedStateHandle[KEY_MEDIA_ID] = mediaSource.id
|
||||||
val subtitleIndex =
|
val subtitleIndex =
|
||||||
streamChoiceService
|
streamChoiceService
|
||||||
.chooseSubtitleStream(
|
.chooseSubtitleStream(
|
||||||
source = mediaSource,
|
source = mediaSource,
|
||||||
audioStream = null,
|
audioStream = null,
|
||||||
seriesId = base.seriesId,
|
seriesId = item.data.seriesId,
|
||||||
itemPlayback = playbackConfig,
|
itemPlayback = playbackConfig,
|
||||||
plc = plc,
|
plc = plc,
|
||||||
prefs = prefs,
|
prefs = prefs,
|
||||||
|
|
@ -252,7 +254,17 @@ class PlayExternalViewModel
|
||||||
putExtra("forcedsrt", subtitleUrls[it])
|
putExtra("forcedsrt", subtitleUrls[it])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
api.playStateApi.reportPlaybackStart(
|
||||||
|
PlaybackStartInfo(
|
||||||
|
canSeek = false,
|
||||||
|
itemId = itemId,
|
||||||
|
isPaused = false,
|
||||||
|
playMethod = PlayMethod.DIRECT_PLAY,
|
||||||
|
repeatMode = RepeatMode.REPEAT_NONE,
|
||||||
|
playbackOrder = PlaybackOrder.DEFAULT,
|
||||||
|
isMuted = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
state.update {
|
state.update {
|
||||||
PlayExternalState(
|
PlayExternalState(
|
||||||
loading = LoadingState.Success,
|
loading = LoadingState.Success,
|
||||||
|
|
@ -278,10 +290,10 @@ class PlayExternalViewModel
|
||||||
return@launchDefault
|
return@launchDefault
|
||||||
}
|
}
|
||||||
Timber.v(
|
Timber.v(
|
||||||
"Result: result=%s, itemId=%s action=%s",
|
"Result: result=%s, action=%s, itemId=%s",
|
||||||
result.resultCode,
|
result.resultCode,
|
||||||
itemId,
|
|
||||||
result.data?.action,
|
result.data?.action,
|
||||||
|
itemId,
|
||||||
)
|
)
|
||||||
if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED ||
|
if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED ||
|
||||||
// Vimu return 1 for video completion
|
// Vimu return 1 for video completion
|
||||||
|
|
@ -295,7 +307,7 @@ class PlayExternalViewModel
|
||||||
position =
|
position =
|
||||||
data
|
data
|
||||||
.getLongExtra("extra_position", Long.MIN_VALUE)
|
.getLongExtra("extra_position", Long.MIN_VALUE)
|
||||||
.takeIf { it >= 0 }
|
.takeIf { it > 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// mpv-android: https://mpv-android.github.io/mpv-android/intent.html
|
// mpv-android: https://mpv-android.github.io/mpv-android/intent.html
|
||||||
|
|
@ -326,19 +338,27 @@ class PlayExternalViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Timber.v("Result position: %s", position?.milliseconds)
|
Timber.v("Result position: %s", position?.milliseconds)
|
||||||
api.playStateApi.reportPlaybackStopped(
|
if (position != null || result.data?.action != null) {
|
||||||
PlaybackStopInfo(
|
api.playStateApi.reportPlaybackStopped(
|
||||||
itemId = itemId,
|
PlaybackStopInfo(
|
||||||
mediaSourceId = mediaSourceId,
|
itemId = itemId,
|
||||||
positionTicks = position?.milliseconds?.inWholeTicks,
|
mediaSourceId = mediaSourceId,
|
||||||
failed = false,
|
positionTicks = position?.milliseconds?.inWholeTicks,
|
||||||
),
|
failed = false,
|
||||||
)
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.w("Activity result: %s", result.resultCode)
|
Timber.w(
|
||||||
|
"Activity result: %s, action=%s",
|
||||||
|
result.resultCode,
|
||||||
|
result.data?.action,
|
||||||
|
)
|
||||||
showToast(context, "Unknown result from external player")
|
showToast(context, "Unknown result from external player")
|
||||||
}
|
}
|
||||||
navigationManager.goBack()
|
navigationManager.goBack()
|
||||||
|
state.update { PlayExternalState() }
|
||||||
|
launched.update { false }
|
||||||
} catch (_: CancellationException) {
|
} catch (_: CancellationException) {
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Error during external playback of %s", itemId)
|
Timber.e(ex, "Error during external playback of %s", itemId)
|
||||||
|
|
@ -359,7 +379,7 @@ class PlayExternalViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
data class PlayExternalState(
|
data class PlayExternalState(
|
||||||
val loading: LoadingState = LoadingState.Loading,
|
val loading: LoadingState = LoadingState.Pending,
|
||||||
val intent: Intent = Intent(),
|
val intent: Intent = Intent(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -369,8 +389,8 @@ fun PlayExternalPage(
|
||||||
destination: Destination,
|
destination: Destination,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: PlayExternalViewModel =
|
viewModel: PlayExternalViewModel =
|
||||||
hiltViewModel<PlayExternalViewModel, PlayExternalViewModel.Factory>(
|
hiltViewModel(
|
||||||
creationCallback = { it.create(destination) },
|
viewModelStoreOwner = LocalContext.current.findActivity() as AppCompatActivity,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val launcher =
|
val launcher =
|
||||||
|
|
@ -380,15 +400,18 @@ fun PlayExternalPage(
|
||||||
)
|
)
|
||||||
|
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
var launched by rememberSaveable { mutableStateOf(false) }
|
val launched by viewModel.launched.collectAsState()
|
||||||
if (!launched) {
|
LaunchedEffect(Unit) {
|
||||||
LaunchedEffect(Unit) {
|
if (!launched) {
|
||||||
viewModel.init()
|
viewModel.init(destination)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val l = state.loading) {
|
when (val l = state.loading) {
|
||||||
LoadingState.Pending,
|
LoadingState.Pending -> {
|
||||||
|
LoadingPage(modifier, false)
|
||||||
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage(modifier)
|
LoadingPage(modifier)
|
||||||
|
|
@ -403,7 +426,7 @@ fun PlayExternalPage(
|
||||||
if (!launched) {
|
if (!launched) {
|
||||||
LifecycleStartEffect(Unit) {
|
LifecycleStartEffect(Unit) {
|
||||||
Timber.i("Launching external playback")
|
Timber.i("Launching external playback")
|
||||||
launched = true
|
viewModel.launched.update { true }
|
||||||
try {
|
try {
|
||||||
launcher.launch(state.intent)
|
launcher.launch(state.intent)
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.hardware.display.DisplayManager
|
|
||||||
import android.view.Display
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.produceState
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.tv.material3.MaterialTheme
|
|
||||||
import androidx.tv.material3.ProvideTextStyle
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
|
||||||
import com.github.damontecres.wholphin.ui.formatBitrate
|
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import org.jellyfin.sdk.model.api.TranscodingInfo
|
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.Locale
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PlaybackDebugOverlay(
|
|
||||||
currentPlayback: CurrentPlayback?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val display =
|
|
||||||
remember(context) {
|
|
||||||
try {
|
|
||||||
val displayManager =
|
|
||||||
context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
|
|
||||||
displayManager?.getDisplay(Display.DEFAULT_DISPLAY)
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val displayMode by produceState<String?>(null) {
|
|
||||||
while (isActive) {
|
|
||||||
value =
|
|
||||||
display?.mode?.let {
|
|
||||||
val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate)
|
|
||||||
"${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}"
|
|
||||||
}
|
|
||||||
delay(10.seconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(48.dp),
|
|
||||||
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
|
|
||||||
) {
|
|
||||||
ProvideTextStyle(MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface)) {
|
|
||||||
SimpleTable(
|
|
||||||
buildList {
|
|
||||||
add("Backend:" to currentPlayback?.backend?.toString())
|
|
||||||
add("Play method:" to currentPlayback?.playMethod?.serialName)
|
|
||||||
if (currentPlayback?.backend == PlayerBackend.EXO_PLAYER) {
|
|
||||||
add("Video Decoder:" to currentPlayback.videoDecoder)
|
|
||||||
add("Audio Decoder:" to currentPlayback.audioDecoder)
|
|
||||||
}
|
|
||||||
add("Display Mode: " to displayMode?.toString())
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
|
||||||
)
|
|
||||||
currentPlayback?.transcodeInfo?.let {
|
|
||||||
TranscodeInfo(it, Modifier.weight(2f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentPlayback?.tracks?.letNotEmpty {
|
|
||||||
PlaybackTrackInfo(
|
|
||||||
trackSupport = it,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TranscodeInfo(
|
|
||||||
info: TranscodingInfo,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
SimpleTable(
|
|
||||||
listOf(
|
|
||||||
"Reason:" to info.transcodeReasons.joinToString(", "),
|
|
||||||
"HW Accel:" to info.hardwareAccelerationType?.toString(),
|
|
||||||
"Container:" to info.container,
|
|
||||||
"Bitrate:" to info.bitrate?.let { formatBitrate(it) },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
SimpleTable(
|
|
||||||
listOf(
|
|
||||||
"Video:" to "${info.videoCodec}, ${info.width}x${info.height}",
|
|
||||||
"Video Direct:" to info.isVideoDirect.toString(),
|
|
||||||
"Audio:" to "${info.audioCodec}, ch=${info.audioChannels}",
|
|
||||||
"Audio Direct:" to info.isAudioDirect.toString(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SimpleTable(
|
|
||||||
rows: List<Pair<String, String?>>,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
rows.forEach {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = it.first,
|
|
||||||
modifier = Modifier.width(100.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = it.second.toString(),
|
|
||||||
modifier = Modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -36,11 +36,14 @@ import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
||||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
enum class PlaybackDialogType {
|
enum class PlaybackDialogType {
|
||||||
MORE,
|
DEBUG,
|
||||||
CAPTIONS,
|
CAPTIONS,
|
||||||
SETTINGS,
|
SETTINGS,
|
||||||
AUDIO,
|
AUDIO,
|
||||||
|
|
@ -65,10 +68,10 @@ data class PlaybackSettings(
|
||||||
/**
|
/**
|
||||||
* Centralized UI component for displaying dialogs during playback
|
* Centralized UI component for displaying dialogs during playback
|
||||||
*
|
*
|
||||||
* Typically, the user will click something generating a [PlaybackAction] which translates into the
|
* Typically, the user will click something generating a [com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction] which translates into the
|
||||||
* [PlaybackDialogType] determining which dialog is shown by this component.
|
* [PlaybackDialogType] determining which dialog is shown by this component.
|
||||||
*
|
*
|
||||||
* @see PlaybackAction
|
* @see com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaybackDialog(
|
fun PlaybackDialog(
|
||||||
|
|
@ -83,28 +86,8 @@ fun PlaybackDialog(
|
||||||
onChangeSubtitleDelay: (Duration) -> Unit,
|
onChangeSubtitleDelay: (Duration) -> Unit,
|
||||||
) {
|
) {
|
||||||
when (type) {
|
when (type) {
|
||||||
PlaybackDialogType.MORE -> {
|
PlaybackDialogType.DEBUG -> {
|
||||||
val options =
|
throw IllegalStateException("Should not open a dialog with " + PlaybackDialogType.DEBUG)
|
||||||
buildList {
|
|
||||||
add(
|
|
||||||
BottomDialogItem(
|
|
||||||
data = 0,
|
|
||||||
headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info),
|
|
||||||
supporting = null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
BottomDialog(
|
|
||||||
choices = options,
|
|
||||||
onDismissRequest = {
|
|
||||||
onDismissRequest.invoke()
|
|
||||||
// focusRequester.tryRequestFocus()
|
|
||||||
},
|
|
||||||
onSelectChoice = { index, choice ->
|
|
||||||
onPlaybackActionClick.invoke(PlaybackAction.ShowDebug)
|
|
||||||
},
|
|
||||||
gravity = Gravity.START,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackDialogType.CAPTIONS -> {
|
PlaybackDialogType.CAPTIONS -> {
|
||||||
|
|
@ -135,17 +118,8 @@ fun PlaybackDialog(
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackDialogType.SETTINGS -> {
|
PlaybackDialogType.SETTINGS -> {
|
||||||
val currentAudio =
|
|
||||||
remember(settings) { settings.audioStreams.firstOrNull { it.index == settings.audioIndex } }
|
|
||||||
val options =
|
val options =
|
||||||
buildList {
|
buildList {
|
||||||
add(
|
|
||||||
BottomDialogItem(
|
|
||||||
data = PlaybackDialogType.AUDIO,
|
|
||||||
headline = stringResource(R.string.audio),
|
|
||||||
supporting = currentAudio?.displayTitle,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
add(
|
add(
|
||||||
BottomDialogItem(
|
BottomDialogItem(
|
||||||
data = PlaybackDialogType.PLAYBACK_SPEED,
|
data = PlaybackDialogType.PLAYBACK_SPEED,
|
||||||
|
|
@ -174,15 +148,26 @@ fun PlaybackDialog(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
add(
|
||||||
|
BottomDialogItem(
|
||||||
|
data = PlaybackDialogType.DEBUG,
|
||||||
|
headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info),
|
||||||
|
supporting = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
BottomDialog(
|
BottomDialog(
|
||||||
choices = options,
|
choices = options,
|
||||||
currentChoice = null,
|
currentChoice = null,
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
onSelectChoice = { _, choice ->
|
onSelectChoice = { _, choice ->
|
||||||
onClickPlaybackDialogType(choice.data)
|
if (choice.data == PlaybackDialogType.DEBUG) {
|
||||||
|
onPlaybackActionClick.invoke(PlaybackAction.ShowDebug)
|
||||||
|
} else {
|
||||||
|
onClickPlaybackDialogType(choice.data)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
gravity = Gravity.END,
|
gravity = Gravity.START,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,10 +178,6 @@ fun PlaybackDialog(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
onControllerInteraction.invoke()
|
onControllerInteraction.invoke()
|
||||||
onDismissRequest.invoke()
|
onDismissRequest.invoke()
|
||||||
// scope.launch {
|
|
||||||
// delay(250L)
|
|
||||||
// settingsFocusRequester.tryRequestFocus()
|
|
||||||
// }
|
|
||||||
},
|
},
|
||||||
onSelectChoice = { _, choice ->
|
onSelectChoice = { _, choice ->
|
||||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index))
|
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index))
|
||||||
|
|
@ -220,15 +201,11 @@ fun PlaybackDialog(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
onControllerInteraction.invoke()
|
onControllerInteraction.invoke()
|
||||||
onDismissRequest.invoke()
|
onDismissRequest.invoke()
|
||||||
// scope.launch {
|
|
||||||
// delay(250L)
|
|
||||||
// settingsFocusRequester.tryRequestFocus()
|
|
||||||
// }
|
|
||||||
},
|
},
|
||||||
onSelectChoice = { _, value ->
|
onSelectChoice = { _, value ->
|
||||||
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data))
|
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data))
|
||||||
},
|
},
|
||||||
gravity = Gravity.END,
|
gravity = Gravity.START,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,15 +224,11 @@ fun PlaybackDialog(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
onControllerInteraction.invoke()
|
onControllerInteraction.invoke()
|
||||||
onDismissRequest.invoke()
|
onDismissRequest.invoke()
|
||||||
// scope.launch {
|
|
||||||
// delay(250L)
|
|
||||||
// settingsFocusRequester.tryRequestFocus()
|
|
||||||
// }
|
|
||||||
},
|
},
|
||||||
onSelectChoice = { _, choice ->
|
onSelectChoice = { _, choice ->
|
||||||
onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data))
|
onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data))
|
||||||
},
|
},
|
||||||
gravity = Gravity.END,
|
gravity = Gravity.START,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,705 +0,0 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.core.Spring
|
|
||||||
import androidx.compose.animation.core.VisibilityThreshold
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.animation.core.spring
|
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.animation.slideIn
|
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOut
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
|
||||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
|
||||||
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.focusProperties
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|
||||||
import androidx.compose.ui.input.key.type
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.media3.common.Player
|
|
||||||
import androidx.tv.material3.MaterialTheme
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import com.github.damontecres.wholphin.R
|
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
|
||||||
import com.github.damontecres.wholphin.data.model.Chapter
|
|
||||||
import com.github.damontecres.wholphin.data.model.Playlist
|
|
||||||
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
|
||||||
import com.github.damontecres.wholphin.ui.cards.ChapterCard
|
|
||||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
|
||||||
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
|
||||||
import com.github.damontecres.wholphin.ui.getTimeFormatter
|
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
|
||||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
|
||||||
import java.time.LocalTime
|
|
||||||
import kotlin.time.Duration
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
private val titleTextSize = 28.sp
|
|
||||||
private val subtitleTextSize = 18.sp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The overlay during playback showing controls, seek preview image, debug info, etc
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun PlaybackOverlay(
|
|
||||||
item: BaseItem?,
|
|
||||||
chapters: List<Chapter>,
|
|
||||||
playerControls: Player,
|
|
||||||
controllerViewState: ControllerViewState,
|
|
||||||
showPlay: Boolean,
|
|
||||||
showClock: Boolean,
|
|
||||||
previousEnabled: Boolean,
|
|
||||||
nextEnabled: Boolean,
|
|
||||||
seekEnabled: Boolean,
|
|
||||||
seekBack: Duration,
|
|
||||||
skipBackOnResume: Duration?,
|
|
||||||
seekForward: Duration,
|
|
||||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
|
||||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
|
||||||
onSeekBarChange: (Long) -> Unit,
|
|
||||||
showDebugInfo: Boolean,
|
|
||||||
currentPlayback: CurrentPlayback?,
|
|
||||||
currentSegment: MediaSegmentDto?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
trickplayInfo: TrickplayInfo? = null,
|
|
||||||
trickplayUrlFor: (Int) -> String? = { null },
|
|
||||||
playlist: Playlist = Playlist(listOf(), 0),
|
|
||||||
onClickPlaylist: (BaseItem) -> Unit = {},
|
|
||||||
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
|
||||||
) {
|
|
||||||
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
|
||||||
// Will be used for preview/trick play images
|
|
||||||
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) }
|
|
||||||
var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat()
|
|
||||||
|
|
||||||
val density = LocalDensity.current
|
|
||||||
|
|
||||||
val titleHeight =
|
|
||||||
remember(item?.title) {
|
|
||||||
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
|
|
||||||
}
|
|
||||||
val subtitleHeight =
|
|
||||||
remember(item?.subtitleLong) {
|
|
||||||
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
|
|
||||||
}
|
|
||||||
|
|
||||||
// This will be calculated after composition
|
|
||||||
var controllerHeight by remember { mutableStateOf(0.dp) }
|
|
||||||
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier,
|
|
||||||
contentAlignment = Alignment.BottomCenter,
|
|
||||||
) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = controllerViewState.controlsVisible,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
modifier = Modifier.matchParentSize(),
|
|
||||||
) {
|
|
||||||
// Background scrim for OSD readability
|
|
||||||
val scrimBrush =
|
|
||||||
remember {
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colors =
|
|
||||||
listOf(
|
|
||||||
Color.Transparent,
|
|
||||||
Color.Black.copy(alpha = 0.5f),
|
|
||||||
Color.Black.copy(alpha = 0.80f),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(scrimBrush),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = controllerViewState.controlsVisible && state == OverlayViewState.CONTROLLER,
|
|
||||||
enter = slideInVertically { it / 2 } + fadeIn(),
|
|
||||||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
|
||||||
) {
|
|
||||||
if (seekBarFocused) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
seekProgressPercent =
|
|
||||||
(playerControls.currentPosition.toFloat() / playerControls.duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val nextState =
|
|
||||||
if (chapters.isNotEmpty()) {
|
|
||||||
OverlayViewState.CHAPTERS
|
|
||||||
} else if (playlist.hasNext()) {
|
|
||||||
OverlayViewState.QUEUE
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(bottom = 8.dp)
|
|
||||||
.onGloballyPositioned {
|
|
||||||
controllerHeight = with(density) { it.size.height.toDp() }
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Controller(
|
|
||||||
title = item?.title,
|
|
||||||
subtitle = item?.subtitleLong,
|
|
||||||
playerControls = playerControls,
|
|
||||||
controllerViewState = controllerViewState,
|
|
||||||
showPlay = showPlay,
|
|
||||||
showClock = showClock,
|
|
||||||
previousEnabled = previousEnabled,
|
|
||||||
nextEnabled = nextEnabled,
|
|
||||||
seekEnabled = seekEnabled,
|
|
||||||
seekBack = seekBack,
|
|
||||||
skipBackOnResume = skipBackOnResume,
|
|
||||||
seekForward = seekForward,
|
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
|
||||||
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
|
||||||
onSeekProgress = {
|
|
||||||
onSeekBarChange(it)
|
|
||||||
seekProgressMs = it
|
|
||||||
},
|
|
||||||
seekBarInteractionSource = seekBarInteractionSource,
|
|
||||||
nextState = nextState,
|
|
||||||
onNextStateFocus = {
|
|
||||||
nextState?.let { state = it }
|
|
||||||
},
|
|
||||||
currentSegment = currentSegment,
|
|
||||||
modifier =
|
|
||||||
Modifier,
|
|
||||||
// Don't use key events because this control has vertical items so up/down is tough to manage
|
|
||||||
)
|
|
||||||
when (nextState) {
|
|
||||||
OverlayViewState.CHAPTERS -> {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.chapters),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(start = 16.dp, top = 0.dp)
|
|
||||||
.onFocusChanged {
|
|
||||||
if (it.isFocused) state = nextState
|
|
||||||
}.focusable(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
OverlayViewState.QUEUE -> {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.queue),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(start = 16.dp, top = 0.dp)
|
|
||||||
.onFocusChanged {
|
|
||||||
if (it.isFocused) state = nextState
|
|
||||||
}.focusable(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = controllerViewState.controlsVisible && state == OverlayViewState.CHAPTERS,
|
|
||||||
enter = slideInVertically { it / 2 } + fadeIn(),
|
|
||||||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
|
||||||
) {
|
|
||||||
if (chapters.isNotEmpty()) {
|
|
||||||
val chapterInteractionSources =
|
|
||||||
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
|
|
||||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
|
||||||
val chapterIndex =
|
|
||||||
remember {
|
|
||||||
val position = playerControls.currentPosition.milliseconds
|
|
||||||
val index =
|
|
||||||
chapters
|
|
||||||
.indexOfFirst { it.position > position }
|
|
||||||
.minus(1)
|
|
||||||
.let {
|
|
||||||
if (it < 0) {
|
|
||||||
// Didn't find a chapter, so it's either the first or last
|
|
||||||
if (position < chapters.first().position) {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
chapters.lastIndex
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
}.coerceIn(0, chapters.lastIndex)
|
|
||||||
index
|
|
||||||
}
|
|
||||||
val listState = rememberLazyListState(chapterIndex)
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
bringIntoViewRequester.bringIntoView()
|
|
||||||
focusRequester.tryRequestFocus()
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(horizontal = 8.dp)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.onPreviewKeyEvent { e ->
|
|
||||||
if (e.type == KeyEventType.KeyUp && isUp(e)) {
|
|
||||||
state = OverlayViewState.CONTROLLER
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.chapters),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
)
|
|
||||||
LazyRow(
|
|
||||||
state = listState,
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.focusRestorer(focusRequester)
|
|
||||||
.onFocusChanged {
|
|
||||||
if (it.hasFocus) {
|
|
||||||
controllerViewState.pulseControls()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
itemsIndexed(chapters) { index, chapter ->
|
|
||||||
val interactionSource = chapterInteractionSources[index]
|
|
||||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
|
||||||
LaunchedEffect(isFocused) {
|
|
||||||
if (isFocused) controllerViewState.pulseControls()
|
|
||||||
}
|
|
||||||
ChapterCard(
|
|
||||||
name = chapter.name,
|
|
||||||
position = chapter.position,
|
|
||||||
imageUrl = chapter.imageUrl,
|
|
||||||
aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE,
|
|
||||||
onClick = {
|
|
||||||
playerControls.seekTo(chapter.position.inWholeMilliseconds)
|
|
||||||
controllerViewState.hideControls()
|
|
||||||
},
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.ifElse(
|
|
||||||
index == chapterIndex,
|
|
||||||
Modifier
|
|
||||||
.focusRequester(focusRequester)
|
|
||||||
.bringIntoViewRequester(bringIntoViewRequester),
|
|
||||||
).ifElse(
|
|
||||||
index == 0,
|
|
||||||
Modifier.focusProperties {
|
|
||||||
// Prevent scrolling left on first card to prevent moving down
|
|
||||||
left = FocusRequester.Cancel
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (playlist.hasNext()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.queue),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(bottom = 8.dp)
|
|
||||||
.onFocusChanged {
|
|
||||||
if (it.isFocused) state = OverlayViewState.QUEUE
|
|
||||||
}.focusable(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = controllerViewState.controlsVisible && state == OverlayViewState.QUEUE,
|
|
||||||
enter = slideInVertically { it / 2 } + fadeIn(),
|
|
||||||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
|
||||||
) {
|
|
||||||
if (playlist.hasNext()) {
|
|
||||||
val items = remember { playlist.upcomingItems() }
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(8.dp)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.onPreviewKeyEvent { e ->
|
|
||||||
if (e.type == KeyEventType.KeyUp && isUp(e)) {
|
|
||||||
if (chapters.isNotEmpty()) {
|
|
||||||
state = OverlayViewState.CHAPTERS
|
|
||||||
} else {
|
|
||||||
state = OverlayViewState.CONTROLLER
|
|
||||||
}
|
|
||||||
true
|
|
||||||
} else if (isDown(e)) {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.queue),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
)
|
|
||||||
LazyRow(
|
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.focusRestorer(focusRequester)
|
|
||||||
.onFocusChanged {
|
|
||||||
if (it.hasFocus) {
|
|
||||||
controllerViewState.pulseControls()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
itemsIndexed(items) { index, item ->
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
|
||||||
LaunchedEffect(isFocused) {
|
|
||||||
if (isFocused) controllerViewState.pulseControls()
|
|
||||||
}
|
|
||||||
SeasonCard(
|
|
||||||
item = item,
|
|
||||||
onClick = {
|
|
||||||
onClickPlaylist.invoke(item)
|
|
||||||
controllerViewState.hideControls()
|
|
||||||
},
|
|
||||||
onLongClick = {},
|
|
||||||
imageHeight = 140.dp,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
modifier =
|
|
||||||
Modifier.ifElse(
|
|
||||||
index == 0,
|
|
||||||
Modifier.focusRequester(focusRequester),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trickplay
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
|
|
||||||
enter =
|
|
||||||
expandVertically(
|
|
||||||
spring(
|
|
||||||
stiffness = Spring.StiffnessMedium,
|
|
||||||
visibilityThreshold = IntSize.VisibilityThreshold,
|
|
||||||
),
|
|
||||||
) + fadeIn(),
|
|
||||||
exit = shrinkVertically() + fadeOut(),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.Center)
|
|
||||||
.fillMaxWidth(.95f),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.offsetByPercent(
|
|
||||||
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
|
|
||||||
).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
|
|
||||||
) {
|
|
||||||
if (trickplayInfo != null) {
|
|
||||||
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
|
|
||||||
val index =
|
|
||||||
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
|
|
||||||
val imageUrl = remember(index) { trickplayUrlFor(index) }
|
|
||||||
|
|
||||||
if (imageUrl != null) {
|
|
||||||
SeekPreviewImage(
|
|
||||||
modifier = Modifier,
|
|
||||||
previewImageUrl = imageUrl,
|
|
||||||
seekProgressMs = seekProgressMs,
|
|
||||||
trickPlayInfo = trickplayInfo,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = (seekProgressMs / 1000L).seconds.toString(),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.background(
|
|
||||||
Color.Black.copy(alpha = 0.6f),
|
|
||||||
shape = RoundedCornerShape(4.dp),
|
|
||||||
).padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Top
|
|
||||||
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
|
|
||||||
enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(),
|
|
||||||
exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopStart),
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = logoImageUrl,
|
|
||||||
contentDescription = "Logo",
|
|
||||||
alignment = Alignment.TopStart,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.size(width = 240.dp, height = 120.dp)
|
|
||||||
.padding(16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = !showDebugInfo && showClock && controllerViewState.controlsVisible,
|
|
||||||
enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(),
|
|
||||||
exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopEnd),
|
|
||||||
) {
|
|
||||||
TimeDisplay()
|
|
||||||
}
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = showDebugInfo && controllerViewState.controlsVisible,
|
|
||||||
enter = slideInVertically() + fadeIn(),
|
|
||||||
exit = slideOutVertically() + fadeOut(),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopStart),
|
|
||||||
) {
|
|
||||||
PlaybackDebugOverlay(
|
|
||||||
currentPlayback = currentPlayback,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopStart)
|
|
||||||
.padding(8.dp)
|
|
||||||
.background(AppColors.TransparentBlack50),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The view state of the overlay
|
|
||||||
*/
|
|
||||||
enum class OverlayViewState {
|
|
||||||
CONTROLLER,
|
|
||||||
CHAPTERS,
|
|
||||||
QUEUE,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A wrapper for the playback controls to show title and other information, plus the actual controls
|
|
||||||
*
|
|
||||||
* @see PlaybackControls
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Controller(
|
|
||||||
title: String?,
|
|
||||||
playerControls: Player,
|
|
||||||
controllerViewState: ControllerViewState,
|
|
||||||
showClock: Boolean,
|
|
||||||
showPlay: Boolean,
|
|
||||||
previousEnabled: Boolean,
|
|
||||||
nextEnabled: Boolean,
|
|
||||||
seekEnabled: Boolean,
|
|
||||||
seekBack: Duration,
|
|
||||||
skipBackOnResume: Duration?,
|
|
||||||
seekForward: Duration,
|
|
||||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
|
||||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
|
||||||
onSeekProgress: (Long) -> Unit,
|
|
||||||
nextState: OverlayViewState?,
|
|
||||||
currentSegment: MediaSegmentDto?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
subtitle: String? = null,
|
|
||||||
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
|
||||||
onNextStateFocus: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
|
||||||
val verticalOffset by animateDpAsState(
|
|
||||||
targetValue = if (seekBarFocused) (-32).dp else 0.dp,
|
|
||||||
label = "TitleBumpOffset",
|
|
||||||
animationSpec =
|
|
||||||
spring(
|
|
||||||
stiffness = Spring.StiffnessMediumLow,
|
|
||||||
visibilityThreshold = Dp.VisibilityThreshold,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(start = 16.dp)
|
|
||||||
.offset(y = verticalOffset),
|
|
||||||
) {
|
|
||||||
title?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontSize = titleTextSize,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.MiddleEllipsis,
|
|
||||||
modifier = Modifier.fillMaxWidth(.75f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.align(Alignment.End),
|
|
||||||
) {
|
|
||||||
subtitle?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontSize = subtitleTextSize,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.MiddleEllipsis,
|
|
||||||
modifier = Modifier.fillMaxWidth(.75f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var endTimeStr by remember { mutableStateOf("...") }
|
|
||||||
LaunchedEffect(playerControls) {
|
|
||||||
while (isActive) {
|
|
||||||
val remaining =
|
|
||||||
(playerControls.duration - playerControls.currentPosition)
|
|
||||||
.div(playerControls.playbackParameters.speed)
|
|
||||||
.toLong()
|
|
||||||
.milliseconds
|
|
||||||
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
|
|
||||||
endTimeStr = getTimeFormatter().format(endTime)
|
|
||||||
delay(1.seconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "Ends $endTimeStr",
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(end = 32.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO need to move these up a level?
|
|
||||||
val moreFocusRequester = remember { FocusRequester() }
|
|
||||||
val captionFocusRequester = remember { FocusRequester() }
|
|
||||||
val settingsFocusRequester = remember { FocusRequester() }
|
|
||||||
PlaybackControls(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
playerControls = playerControls,
|
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
|
||||||
controllerViewState = controllerViewState,
|
|
||||||
onSeekProgress = {
|
|
||||||
onSeekProgress(it)
|
|
||||||
},
|
|
||||||
showPlay = showPlay,
|
|
||||||
previousEnabled = previousEnabled,
|
|
||||||
nextEnabled = nextEnabled,
|
|
||||||
seekEnabled = seekEnabled,
|
|
||||||
seekBarInteractionSource = seekBarInteractionSource,
|
|
||||||
seekBarIntervals = 16,
|
|
||||||
seekBack = seekBack,
|
|
||||||
seekForward = seekForward,
|
|
||||||
skipBackOnResume = skipBackOnResume,
|
|
||||||
currentSegment = currentSegment,
|
|
||||||
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
|
||||||
moreFocusRequester = moreFocusRequester,
|
|
||||||
captionFocusRequester = captionFocusRequester,
|
|
||||||
settingsFocusRequester = settingsFocusRequester,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
|
@ -28,6 +29,7 @@ import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -35,12 +37,12 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
|
@ -74,8 +76,13 @@ import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.overlay.PauseIndicator
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackOverlay
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.overlay.SkipIndicator
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.overlay.SkipSegmentButton
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.overlay.rememberSeekBarState
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
|
||||||
|
|
@ -167,6 +174,7 @@ fun PlaybackPageContent(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val currentSegment by viewModel.currentSegment.collectAsState()
|
val currentSegment by viewModel.currentSegment.collectAsState()
|
||||||
|
val analyticsState by viewModel.analyticsState.collectAsState()
|
||||||
|
|
||||||
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
||||||
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
||||||
|
|
@ -389,7 +397,7 @@ fun PlaybackPageContent(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Transparent),
|
.background(Color.Transparent),
|
||||||
item = currentPlayback?.item,
|
item = currentPlayback?.item,
|
||||||
playerControls = player,
|
player = player,
|
||||||
controllerViewState = controllerViewState,
|
controllerViewState = controllerViewState,
|
||||||
showPlay = playPauseState.showPlay,
|
showPlay = playPauseState.showPlay,
|
||||||
previousEnabled = true,
|
previousEnabled = true,
|
||||||
|
|
@ -412,6 +420,7 @@ fun PlaybackPageContent(
|
||||||
},
|
},
|
||||||
currentSegment = currentSegment?.segment,
|
currentSegment = currentSegment?.segment,
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
|
analyticsState = analyticsState,
|
||||||
)
|
)
|
||||||
|
|
||||||
val subtitleSettings =
|
val subtitleSettings =
|
||||||
|
|
@ -427,65 +436,81 @@ fun PlaybackPageContent(
|
||||||
remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f }
|
remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f }
|
||||||
|
|
||||||
// Subtitles
|
// Subtitles
|
||||||
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) {
|
val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
||||||
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
|
||||||
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
|
var cueCount by remember { mutableIntStateOf(0) }
|
||||||
AndroidView(
|
|
||||||
factory = { context ->
|
val subtitleVisible = skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface
|
||||||
SubtitleView(context).apply {
|
|
||||||
subtitleSettings.let {
|
AndroidView(
|
||||||
setStyle(it.toSubtitleStyle())
|
factory = { context ->
|
||||||
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
SubtitleView(context).apply {
|
||||||
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
subtitleSettings.let {
|
||||||
}
|
setStyle(it.toSubtitleStyle())
|
||||||
playerState.assHandler?.let { assHandler ->
|
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
||||||
if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) {
|
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
||||||
Timber.v("Adding AssSubtitleView")
|
}
|
||||||
addView(
|
playerState.assHandler?.let { assHandler ->
|
||||||
AssSubtitleView(context, assHandler).apply {
|
if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) {
|
||||||
layoutParams =
|
Timber.v("Adding AssSubtitleView")
|
||||||
FrameLayout
|
addView(
|
||||||
.LayoutParams(
|
AssSubtitleView(context, assHandler).apply {
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
layoutParams =
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
FrameLayout
|
||||||
).apply { gravity = Gravity.CENTER }
|
.LayoutParams(
|
||||||
},
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
)
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
}
|
).apply { gravity = Gravity.CENTER }
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
update = {
|
},
|
||||||
it.setCues(cues)
|
update = { subtitleView ->
|
||||||
|
subtitleView.setCues(cues)
|
||||||
|
if (cues.size > cueCount) {
|
||||||
|
// The output creates a painter for each cue, so need to apply the changes when the number of cues increases
|
||||||
Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
|
Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
|
||||||
.apply(it)
|
.apply(subtitleView)
|
||||||
it.children.firstOrNull { it is AssSubtitleView }?.let {
|
cueCount = cues.size
|
||||||
(it as? AssSubtitleView)?.apply {
|
}
|
||||||
val resized =
|
subtitleView.children.firstOrNull { it is AssSubtitleView }?.let {
|
||||||
layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height }
|
(it as? AssSubtitleView)?.apply {
|
||||||
if (resized) {
|
val resized =
|
||||||
Timber.v("Resizing AssSubtitleView: $playerSurfaceSize")
|
layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height }
|
||||||
layoutParams =
|
|
||||||
FrameLayout
|
if (resized && playerSurfaceSize.width > 0 && playerSurfaceSize.height > 0) {
|
||||||
.LayoutParams(
|
Timber.v("Resizing AssSubtitleView: %s", playerSurfaceSize)
|
||||||
playerSurfaceSize.width,
|
layoutParams =
|
||||||
playerSurfaceSize.height,
|
FrameLayout
|
||||||
).apply { gravity = Gravity.CENTER }
|
.LayoutParams(
|
||||||
}
|
playerSurfaceSize.width,
|
||||||
|
playerSurfaceSize.height,
|
||||||
|
).apply { gravity = Gravity.CENTER }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
onReset = {
|
},
|
||||||
it.setCues(null)
|
onReset = {
|
||||||
},
|
it.setCues(null)
|
||||||
modifier =
|
},
|
||||||
Modifier
|
modifier =
|
||||||
.fillMaxSize(maxSize)
|
Modifier
|
||||||
.align(Alignment.TopCenter)
|
.fillMaxSize(subtitleMaxSize)
|
||||||
.background(Color.Transparent)
|
.align(Alignment.TopCenter)
|
||||||
.ifElse(isImageSubtitles, Modifier.alpha(subtitleImageOpacity)),
|
.background(Color.Transparent)
|
||||||
)
|
.graphicsLayer {
|
||||||
}
|
alpha =
|
||||||
|
if (!subtitleVisible) {
|
||||||
|
0f
|
||||||
|
} else if (isImageSubtitles) {
|
||||||
|
subtitleImageOpacity
|
||||||
|
} else {
|
||||||
|
1f
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask to skip intros, etc button
|
// Ask to skip intros, etc button
|
||||||
|
|
@ -642,7 +667,15 @@ fun PlaybackPageContent(
|
||||||
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null,
|
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null,
|
||||||
),
|
),
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
playbackDialog = null
|
playbackDialog =
|
||||||
|
when (type) {
|
||||||
|
// Go back to settings dialog
|
||||||
|
PlaybackDialogType.PLAYBACK_SPEED,
|
||||||
|
PlaybackDialogType.VIDEO_SCALE,
|
||||||
|
-> PlaybackDialogType.SETTINGS
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
if (controllerViewState.controlsVisible) {
|
if (controllerViewState.controlsVisible) {
|
||||||
controllerViewState.pulseControls()
|
controllerViewState.pulseControls()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.tv.material3.Icon
|
|
||||||
import androidx.tv.material3.MaterialTheme
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
|
||||||
import com.github.damontecres.wholphin.util.TrackSupport
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Debug info about the current playback tracks
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun PlaybackTrackInfo(
|
|
||||||
trackSupport: List<TrackSupport>,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val selectedWeight = .5f
|
|
||||||
val weights = listOf(.25f, .4f, .5f, 1f, 1f)
|
|
||||||
val textStyle =
|
|
||||||
MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface)
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val texts =
|
|
||||||
listOf(
|
|
||||||
"ID",
|
|
||||||
"Type",
|
|
||||||
"Codec",
|
|
||||||
"Supported",
|
|
||||||
"Labels",
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
modifier = Modifier.weight(selectedWeight),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Selected",
|
|
||||||
style = textStyle,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
texts.forEachIndexed { index, text ->
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
modifier = Modifier.weight(weights[index]),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = textStyle,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items(trackSupport) { track ->
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier.ifElse(
|
|
||||||
track.selected,
|
|
||||||
Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
val texts =
|
|
||||||
listOf(
|
|
||||||
track.id ?: "",
|
|
||||||
track.type.name,
|
|
||||||
track.codecs ?: "",
|
|
||||||
track.supported.name,
|
|
||||||
track.labels.joinToString(", "),
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
modifier = Modifier.weight(selectedWeight),
|
|
||||||
) {
|
|
||||||
if (track.selected) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
|
||||||
modifier = Modifier.size(12.dp),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = "-",
|
|
||||||
style = textStyle,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
texts.forEachIndexed { index, text ->
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
modifier = Modifier.weight(weights[index]),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = textStyle,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.MediaCodecList
|
||||||
|
import android.os.Build
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.ui.text.intl.Locale
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
|
@ -33,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.Chapter
|
import com.github.damontecres.wholphin.data.model.Chapter
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.Playlist
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
|
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
|
|
@ -51,6 +54,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
|
import com.github.damontecres.wholphin.ui.formatBitrate
|
||||||
import com.github.damontecres.wholphin.ui.gt
|
import com.github.damontecres.wholphin.ui.gt
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
|
|
@ -105,6 +109,7 @@ import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||||
|
|
@ -118,6 +123,7 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -175,12 +181,13 @@ class PlaybackViewModel
|
||||||
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
|
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
|
||||||
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
||||||
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
|
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
|
||||||
|
val analyticsState = MutableStateFlow(AnalyticsState())
|
||||||
|
|
||||||
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
||||||
|
|
||||||
private lateinit var preferences: UserPreferences
|
private lateinit var preferences: UserPreferences
|
||||||
internal lateinit var itemId: UUID
|
internal lateinit var itemId: UUID
|
||||||
internal lateinit var item: BaseItem
|
internal lateinit var currentItem: PlaylistItem
|
||||||
internal var forceTranscoding: Boolean = false
|
internal var forceTranscoding: Boolean = false
|
||||||
private var activityListener: TrackActivityPlaybackListener? = null
|
private var activityListener: TrackActivityPlaybackListener? = null
|
||||||
private val jobs = mutableListOf<Job>()
|
private val jobs = mutableListOf<Job>()
|
||||||
|
|
@ -225,8 +232,11 @@ class PlaybackViewModel
|
||||||
) {
|
) {
|
||||||
val softwareDecoding =
|
val softwareDecoding =
|
||||||
!preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding
|
!preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding
|
||||||
|
val requestBackend =
|
||||||
|
(destination as? Destination.Playback)?.backend
|
||||||
|
?: preferences.appPreferences.playbackPreferences.playerBackend
|
||||||
val playerBackend =
|
val playerBackend =
|
||||||
when (preferences.appPreferences.playbackPreferences.playerBackend) {
|
when (requestBackend) {
|
||||||
PlayerBackend.UNRECOGNIZED,
|
PlayerBackend.UNRECOGNIZED,
|
||||||
PlayerBackend.EXO_PLAYER,
|
PlayerBackend.EXO_PLAYER,
|
||||||
-> PlayerBackend.EXO_PLAYER
|
-> PlayerBackend.EXO_PLAYER
|
||||||
|
|
@ -289,21 +299,18 @@ class PlaybackViewModel
|
||||||
this.forceTranscoding =
|
this.forceTranscoding =
|
||||||
(destination as? Destination.Playback)?.forceTranscoding ?: false
|
(destination as? Destination.Playback)?.forceTranscoding ?: false
|
||||||
val positionMs: Long
|
val positionMs: Long
|
||||||
val itemPlayback: ItemPlayback?
|
|
||||||
val forceTranscoding: Boolean
|
val forceTranscoding: Boolean
|
||||||
|
|
||||||
val itemId =
|
val itemId =
|
||||||
when (val d = destination) {
|
when (val d = destination) {
|
||||||
is Destination.Playback -> {
|
is Destination.Playback -> {
|
||||||
positionMs = d.positionMs
|
positionMs = d.positionMs
|
||||||
itemPlayback = d.itemPlayback
|
|
||||||
forceTranscoding = d.forceTranscoding
|
forceTranscoding = d.forceTranscoding
|
||||||
d.itemId
|
d.itemId
|
||||||
}
|
}
|
||||||
|
|
||||||
is Destination.PlaybackList -> {
|
is Destination.PlaybackList -> {
|
||||||
positionMs = 0
|
positionMs = 0
|
||||||
itemPlayback = null
|
|
||||||
forceTranscoding = false
|
forceTranscoding = false
|
||||||
d.itemId
|
d.itemId
|
||||||
}
|
}
|
||||||
|
|
@ -314,9 +321,9 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
this.itemId = itemId
|
this.itemId = itemId
|
||||||
val queriedItem = api.userLibraryApi.getItem(itemId).content
|
val queriedItem = api.userLibraryApi.getItem(itemId).content
|
||||||
val base =
|
val playlistItem =
|
||||||
if (queriedItem.type.playable) {
|
if (queriedItem.type.playable) {
|
||||||
queriedItem
|
PlaylistItem.Media(BaseItem(queriedItem, false))
|
||||||
} else if (destination is Destination.PlaybackList) {
|
} else if (destination is Destination.PlaybackList) {
|
||||||
val playlistResult =
|
val playlistResult =
|
||||||
playlistCreator.createFrom(
|
playlistCreator.createFrom(
|
||||||
|
|
@ -344,9 +351,7 @@ class PlaybackViewModel
|
||||||
this@PlaybackViewModel.playlist.value = r.playlist
|
this@PlaybackViewModel.playlist.value = r.playlist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
r.playlist.items
|
r.playlist.items.first()
|
||||||
.first()
|
|
||||||
.data
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -355,12 +360,39 @@ class PlaybackViewModel
|
||||||
|
|
||||||
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
|
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
|
||||||
|
|
||||||
val item = BaseItem.from(base, api)
|
val intros =
|
||||||
|
// If not resuming playback & cinema mode is enabled, get potential intros
|
||||||
|
if (positionMs == 0L && preferences.appPreferences.playbackPreferences.cinemaMode) {
|
||||||
|
api.userLibraryApi
|
||||||
|
.getIntros(
|
||||||
|
itemId = playlistItem.id,
|
||||||
|
userId = serverRepository.currentUser.value?.id,
|
||||||
|
).content.items
|
||||||
|
.map {
|
||||||
|
PlaylistItem.Intro(BaseItem(it))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
val firstItem =
|
||||||
|
if (intros.isNotEmpty()) {
|
||||||
|
Timber.v("Got %s intros", intros.size)
|
||||||
|
val currentPlaylist =
|
||||||
|
this@PlaybackViewModel
|
||||||
|
.playlist.value
|
||||||
|
?.items
|
||||||
|
.orEmpty()
|
||||||
|
val newPlaylist = Playlist(intros + currentPlaylist)
|
||||||
|
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
|
||||||
|
intros.first()
|
||||||
|
} else {
|
||||||
|
playlistItem
|
||||||
|
}
|
||||||
|
|
||||||
val played =
|
val played =
|
||||||
play(
|
play(
|
||||||
item,
|
firstItem,
|
||||||
positionMs,
|
positionMs,
|
||||||
itemPlayback,
|
|
||||||
forceTranscoding,
|
forceTranscoding,
|
||||||
)
|
)
|
||||||
if (!played) {
|
if (!played) {
|
||||||
|
|
@ -370,9 +402,13 @@ class PlaybackViewModel
|
||||||
if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
|
if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
|
||||||
val result = playlistCreator.createFrom(queriedItem)
|
val result = playlistCreator.createFrom(queriedItem)
|
||||||
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
|
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
|
||||||
withContext(Dispatchers.Main) {
|
val currentPlaylist =
|
||||||
this@PlaybackViewModel.playlist.value = result.playlist
|
this@PlaybackViewModel
|
||||||
}
|
.playlist.value
|
||||||
|
?.items
|
||||||
|
.orEmpty()
|
||||||
|
val newPlaylist = Playlist(currentPlaylist + result.playlist.items)
|
||||||
|
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -380,18 +416,23 @@ class PlaybackViewModel
|
||||||
/**
|
/**
|
||||||
* Play an item
|
* Play an item
|
||||||
*
|
*
|
||||||
* @param item the item to play
|
* @param currentItem the item to play
|
||||||
* @param positionMs the starting playback position in milliseconds
|
* @param positionMs the starting playback position in milliseconds
|
||||||
* @param itemPlayback the parameters for playback such chosen subtitle or audio streams
|
* @param itemPlayback the parameters for playback such chosen subtitle or audio streams
|
||||||
* @param forceTranscoding whether the user has requested to force playback via transcoding
|
* @param forceTranscoding whether the user has requested to force playback via transcoding
|
||||||
*/
|
*/
|
||||||
private suspend fun play(
|
private suspend fun play(
|
||||||
item: BaseItem,
|
playlistItem: PlaylistItem,
|
||||||
positionMs: Long,
|
positionMs: Long,
|
||||||
itemPlayback: ItemPlayback? = null,
|
|
||||||
forceTranscoding: Boolean = this.forceTranscoding,
|
forceTranscoding: Boolean = this.forceTranscoding,
|
||||||
): Boolean =
|
): Boolean =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
val item =
|
||||||
|
when (playlistItem) {
|
||||||
|
is PlaylistItem.Intro -> playlistItem.item
|
||||||
|
is PlaylistItem.Media -> playlistItem.item
|
||||||
|
}
|
||||||
|
|
||||||
Timber.i("Playing ${item.id}")
|
Timber.i("Playing ${item.id}")
|
||||||
|
|
||||||
// New item, so we can clear the media segment tracker & subtitle cues
|
// New item, so we can clear the media segment tracker & subtitle cues
|
||||||
|
|
@ -411,26 +452,25 @@ class PlaybackViewModel
|
||||||
)
|
)
|
||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
this@PlaybackViewModel.item = item
|
this@PlaybackViewModel.currentItem = playlistItem
|
||||||
this@PlaybackViewModel.itemId = item.id
|
this@PlaybackViewModel.itemId = item.id
|
||||||
|
|
||||||
val isLiveTv = item.type == BaseItemKind.TV_CHANNEL
|
val isLiveTv = item.type == BaseItemKind.TV_CHANNEL
|
||||||
val base = item.data
|
val base = item.data
|
||||||
|
|
||||||
// Use the provided playback parameters or else check if the database has some
|
// Use the provided playback parameters or else check if the database has some
|
||||||
val playbackConfig =
|
val itemPlayback =
|
||||||
itemPlayback
|
serverRepository.currentUser.value?.let { user ->
|
||||||
?: serverRepository.currentUser.value?.let { user ->
|
itemPlaybackDao.getItem(user, base.id)?.let {
|
||||||
itemPlaybackDao.getItem(user, base.id)?.let {
|
Timber.v("Fetched itemPlayback from DB: %s", it)
|
||||||
Timber.v("Fetched itemPlayback from DB: %s", it)
|
if (it.sourceId != null) {
|
||||||
if (it.sourceId != null) {
|
it
|
||||||
it
|
} else {
|
||||||
} else {
|
null
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val mediaSource = streamChoiceService.chooseSource(base, playbackConfig)
|
}
|
||||||
|
val mediaSource = streamChoiceService.chooseSource(base, itemPlayback)
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
|
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
|
||||||
|
|
||||||
if (mediaSource == null) {
|
if (mediaSource == null) {
|
||||||
|
|
@ -486,7 +526,7 @@ class PlaybackViewModel
|
||||||
.chooseAudioStream(
|
.chooseAudioStream(
|
||||||
source = mediaSource,
|
source = mediaSource,
|
||||||
seriesId = base.seriesId,
|
seriesId = base.seriesId,
|
||||||
itemPlayback = playbackConfig,
|
itemPlayback = itemPlayback,
|
||||||
plc = plc,
|
plc = plc,
|
||||||
prefs = preferences,
|
prefs = preferences,
|
||||||
)
|
)
|
||||||
|
|
@ -498,7 +538,7 @@ class PlaybackViewModel
|
||||||
source = mediaSource,
|
source = mediaSource,
|
||||||
audioStream = audioStream,
|
audioStream = audioStream,
|
||||||
seriesId = base.seriesId,
|
seriesId = base.seriesId,
|
||||||
itemPlayback = playbackConfig,
|
itemPlayback = itemPlayback,
|
||||||
plc = plc,
|
plc = plc,
|
||||||
prefs = preferences,
|
prefs = preferences,
|
||||||
)?.index
|
)?.index
|
||||||
|
|
@ -506,7 +546,7 @@ class PlaybackViewModel
|
||||||
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
|
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
|
||||||
|
|
||||||
val itemPlaybackToUse =
|
val itemPlaybackToUse =
|
||||||
playbackConfig ?: ItemPlayback(
|
itemPlayback ?: ItemPlayback(
|
||||||
rowId = -1,
|
rowId = -1,
|
||||||
userId = -1,
|
userId = -1,
|
||||||
itemId = base.id,
|
itemId = base.id,
|
||||||
|
|
@ -776,6 +816,7 @@ class PlaybackViewModel
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
|
Timber.v("onTracksChanged: %s", result)
|
||||||
if (result.bothSelected) {
|
if (result.bothSelected) {
|
||||||
player.trackSelectionParameters =
|
player.trackSelectionParameters =
|
||||||
result.trackSelectionParameters
|
result.trackSelectionParameters
|
||||||
|
|
@ -885,7 +926,7 @@ class PlaybackViewModel
|
||||||
Timber.d("Changing audio track to %s", index)
|
Timber.d("Changing audio track to %s", index)
|
||||||
val itemPlayback =
|
val itemPlayback =
|
||||||
itemPlaybackRepository.saveTrackSelection(
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
item = item,
|
item = currentItem.item,
|
||||||
itemPlayback = currentItemPlayback.value!!,
|
itemPlayback = currentItemPlayback.value!!,
|
||||||
trackIndex = index,
|
trackIndex = index,
|
||||||
type = MediaStreamType.AUDIO,
|
type = MediaStreamType.AUDIO,
|
||||||
|
|
@ -899,7 +940,7 @@ class PlaybackViewModel
|
||||||
streamChoiceService.resolveSubtitleIndex(
|
streamChoiceService.resolveSubtitleIndex(
|
||||||
source = source,
|
source = source,
|
||||||
audioStreamIndex = index,
|
audioStreamIndex = index,
|
||||||
seriesId = item.data.seriesId,
|
seriesId = currentItem.item.data.seriesId,
|
||||||
subtitleIndex = itemPlayback.subtitleIndex,
|
subtitleIndex = itemPlayback.subtitleIndex,
|
||||||
prefs = preferences,
|
prefs = preferences,
|
||||||
)
|
)
|
||||||
|
|
@ -908,7 +949,7 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
changeStreams(
|
changeStreams(
|
||||||
item,
|
currentItem.item,
|
||||||
itemPlayback,
|
itemPlayback,
|
||||||
index,
|
index,
|
||||||
resolvedSubtitleIndex,
|
resolvedSubtitleIndex,
|
||||||
|
|
@ -923,7 +964,7 @@ class PlaybackViewModel
|
||||||
Timber.d("Changing subtitle track to %s", index)
|
Timber.d("Changing subtitle track to %s", index)
|
||||||
val itemPlayback =
|
val itemPlayback =
|
||||||
itemPlaybackRepository.saveTrackSelection(
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
item = item,
|
item = currentItem.item,
|
||||||
itemPlayback = currentItemPlayback.value!!,
|
itemPlayback = currentItemPlayback.value!!,
|
||||||
trackIndex = index,
|
trackIndex = index,
|
||||||
type = MediaStreamType.SUBTITLE,
|
type = MediaStreamType.SUBTITLE,
|
||||||
|
|
@ -937,7 +978,7 @@ class PlaybackViewModel
|
||||||
streamChoiceService.resolveSubtitleIndex(
|
streamChoiceService.resolveSubtitleIndex(
|
||||||
source = source,
|
source = source,
|
||||||
audioStreamIndex = itemPlayback.audioIndex,
|
audioStreamIndex = itemPlayback.audioIndex,
|
||||||
seriesId = item.data.seriesId,
|
seriesId = currentItem.item.data.seriesId,
|
||||||
subtitleIndex = index,
|
subtitleIndex = index,
|
||||||
prefs = preferences,
|
prefs = preferences,
|
||||||
)
|
)
|
||||||
|
|
@ -946,7 +987,7 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
changeStreams(
|
changeStreams(
|
||||||
item,
|
currentItem.item,
|
||||||
itemPlayback,
|
itemPlayback,
|
||||||
itemPlayback.audioIndex,
|
itemPlayback.audioIndex,
|
||||||
resolvedIndex,
|
resolvedIndex,
|
||||||
|
|
@ -981,7 +1022,7 @@ class PlaybackViewModel
|
||||||
mediaSourceId: UUID? = currentItemPlayback.value?.sourceId,
|
mediaSourceId: UUID? = currentItemPlayback.value?.sourceId,
|
||||||
): String? =
|
): String? =
|
||||||
trickPlayInfo?.let {
|
trickPlayInfo?.let {
|
||||||
val itemId = item.id
|
val itemId = currentItem.id
|
||||||
return api.trickplayApi.getTrickplayTileImageUrl(
|
return api.trickplayApi.getTrickplayTileImageUrl(
|
||||||
itemId,
|
itemId,
|
||||||
trickPlayInfo.width,
|
trickPlayInfo.width,
|
||||||
|
|
@ -992,12 +1033,28 @@ class PlaybackViewModel
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
if (playbackState == Player.STATE_ENDED) {
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
viewModelScope.launchIO {
|
Timber.v("Playback state is STATE_ENDED")
|
||||||
val nextItem = playlist.value?.peek()
|
viewModelScope.launchDefault {
|
||||||
Timber.v("Setting next up to ${nextItem?.id}")
|
when (val nextItem = playlist.value?.peek()) {
|
||||||
withContext(Dispatchers.Main) {
|
is PlaylistItem.Intro -> {
|
||||||
nextUp.value = nextItem
|
Timber.v("Next item is intro, so playing immediately")
|
||||||
if (nextItem == null) {
|
playNextUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaylistItem.Media -> {
|
||||||
|
if (currentItem is PlaylistItem.Intro) {
|
||||||
|
Timber.v("Current item is intro, so playing next up immediately")
|
||||||
|
playNextUp()
|
||||||
|
} else {
|
||||||
|
Timber.v("Setting next up to ${nextItem.id}")
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
nextUp.value = nextItem.item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> {
|
||||||
|
Timber.v("No next up")
|
||||||
navigationManager.goBack()
|
navigationManager.goBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1063,9 +1120,11 @@ class PlaybackViewModel
|
||||||
outroShownSegments.add(currentSegment.id)
|
outroShownSegments.add(currentSegment.id)
|
||||||
) {
|
) {
|
||||||
val nextItem = playlist.peek()
|
val nextItem = playlist.peek()
|
||||||
Timber.v("Setting next up during outro to ${nextItem?.id}")
|
if (nextItem is PlaylistItem.Media) {
|
||||||
withContext(Dispatchers.Main) {
|
Timber.v("Setting next up during outro to ${nextItem?.id}")
|
||||||
nextUp.value = nextItem
|
withContext(Dispatchers.Main) {
|
||||||
|
nextUp.value = nextItem.item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val behavior =
|
val behavior =
|
||||||
|
|
@ -1190,7 +1249,7 @@ class PlaybackViewModel
|
||||||
fun playNextUp() {
|
fun playNextUp() {
|
||||||
playlist.value?.let {
|
playlist.value?.let {
|
||||||
if (it.hasNext()) {
|
if (it.hasNext()) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchDefault {
|
||||||
cancelUpNextEpisode()
|
cancelUpNextEpisode()
|
||||||
val item = it.getAndAdvance()
|
val item = it.getAndAdvance()
|
||||||
val played = play(item, 0)
|
val played = play(item, 0)
|
||||||
|
|
@ -1205,7 +1264,7 @@ class PlaybackViewModel
|
||||||
fun playPrevious() {
|
fun playPrevious() {
|
||||||
playlist.value?.let {
|
playlist.value?.let {
|
||||||
if (it.hasPrevious()) {
|
if (it.hasPrevious()) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchDefault {
|
||||||
cancelUpNextEpisode()
|
cancelUpNextEpisode()
|
||||||
val item = it.getPreviousAndReverse()
|
val item = it.getPreviousAndReverse()
|
||||||
val played = play(item, 0)
|
val played = play(item, 0)
|
||||||
|
|
@ -1266,7 +1325,7 @@ class PlaybackViewModel
|
||||||
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
|
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
|
||||||
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding")
|
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding")
|
||||||
changeStreams(
|
changeStreams(
|
||||||
item,
|
currentItem.item,
|
||||||
currentItemPlayback.value!!,
|
currentItemPlayback.value!!,
|
||||||
currentItemPlayback.value?.audioIndex,
|
currentItemPlayback.value?.audioIndex,
|
||||||
currentItemPlayback.value?.subtitleIndex,
|
currentItemPlayback.value?.subtitleIndex,
|
||||||
|
|
@ -1358,6 +1417,35 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateDecoder(
|
||||||
|
decoderName: String,
|
||||||
|
type: MediaType,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
val codecInfo =
|
||||||
|
MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||||
|
.codecInfos
|
||||||
|
.firstOrNull { !it.isEncoder && it.name == decoderName }
|
||||||
|
val decoderString =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
if (codecInfo?.isHardwareAccelerated == true) {
|
||||||
|
"$decoderName (HW)"
|
||||||
|
} else {
|
||||||
|
decoderName
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
decoderName
|
||||||
|
}
|
||||||
|
currentPlayback.update {
|
||||||
|
when (type) {
|
||||||
|
MediaType.VIDEO -> it?.copy(videoDecoder = decoderString)
|
||||||
|
MediaType.AUDIO -> it?.copy(audioDecoder = decoderString)
|
||||||
|
else -> throw IllegalArgumentException("Unsupported type: $type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onVideoDecoderInitialized(
|
override fun onVideoDecoderInitialized(
|
||||||
eventTime: AnalyticsListener.EventTime,
|
eventTime: AnalyticsListener.EventTime,
|
||||||
decoderName: String,
|
decoderName: String,
|
||||||
|
|
@ -1365,7 +1453,7 @@ class PlaybackViewModel
|
||||||
initializationDurationMs: Long,
|
initializationDurationMs: Long,
|
||||||
) {
|
) {
|
||||||
Timber.v("onVideoDecoderInitialized: decoder=$decoderName")
|
Timber.v("onVideoDecoderInitialized: decoder=$decoderName")
|
||||||
currentPlayback.update { it?.copy(videoDecoder = decoderName) }
|
updateDecoder(decoderName, MediaType.VIDEO)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onVideoDisabled(
|
override fun onVideoDisabled(
|
||||||
|
|
@ -1384,7 +1472,7 @@ class PlaybackViewModel
|
||||||
decoderReuseEvaluation?.let { decoder ->
|
decoderReuseEvaluation?.let { decoder ->
|
||||||
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
|
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
|
||||||
Timber.d("onVideoInputFormatChanged: decoder=${decoder.decoderName}")
|
Timber.d("onVideoInputFormatChanged: decoder=${decoder.decoderName}")
|
||||||
currentPlayback.update { it?.copy(videoDecoder = decoder.decoderName) }
|
updateDecoder(decoder.decoderName, MediaType.VIDEO)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1396,7 +1484,7 @@ class PlaybackViewModel
|
||||||
initializationDurationMs: Long,
|
initializationDurationMs: Long,
|
||||||
) {
|
) {
|
||||||
Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName")
|
Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName")
|
||||||
currentPlayback.update { it?.copy(audioDecoder = decoderName) }
|
updateDecoder(decoderName, MediaType.AUDIO)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAudioInputFormatChanged(
|
override fun onAudioInputFormatChanged(
|
||||||
|
|
@ -1407,7 +1495,7 @@ class PlaybackViewModel
|
||||||
decoderReuseEvaluation?.let { decoder ->
|
decoderReuseEvaluation?.let { decoder ->
|
||||||
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
|
if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) {
|
||||||
Timber.d("decoder: onAudioInputFormatChanged: decoder=${decoder.decoderName}")
|
Timber.d("decoder: onAudioInputFormatChanged: decoder=${decoder.decoderName}")
|
||||||
currentPlayback.update { it?.copy(audioDecoder = decoder.decoderName) }
|
updateDecoder(decoder.decoderName, MediaType.AUDIO)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1466,6 +1554,37 @@ class PlaybackViewModel
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
screensaverService.keepScreenOn(isPlaying)
|
screensaverService.keepScreenOn(isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onBandwidthEstimate(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
totalLoadTimeMs: Int,
|
||||||
|
totalBytesLoaded: Long,
|
||||||
|
bitrateEstimate: Long,
|
||||||
|
) {
|
||||||
|
Timber.v(
|
||||||
|
"onBandwidthEstimate: totalLoadTimeMs=%s, totalBytesLoaded=%s, bitrateEstimate=%s",
|
||||||
|
totalLoadTimeMs,
|
||||||
|
totalBytesLoaded,
|
||||||
|
bitrateEstimate,
|
||||||
|
)
|
||||||
|
if (totalLoadTimeMs > 0 && totalBytesLoaded > 0) {
|
||||||
|
analyticsState.update {
|
||||||
|
it.copy(
|
||||||
|
bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()),
|
||||||
|
bitrateEstimate = formatBitrate(bitrateEstimate.toInt()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDroppedVideoFrames(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
droppedFrames: Int,
|
||||||
|
elapsedMs: Long,
|
||||||
|
) {
|
||||||
|
// Timber.v("onDroppedVideoFrames: droppedFrames=%s", droppedFrames)
|
||||||
|
analyticsState.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class PlayerState(
|
data class PlayerState(
|
||||||
|
|
@ -1478,3 +1597,9 @@ data class MediaSegmentState(
|
||||||
val segment: MediaSegmentDto,
|
val segment: MediaSegmentDto,
|
||||||
val interacted: Boolean,
|
val interacted: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class AnalyticsState(
|
||||||
|
val bitrate: String = formatBitrate(0),
|
||||||
|
val bitrateEstimate: String = formatBitrate(0),
|
||||||
|
val droppedFrames: Int = 0,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.compose.ui.text.intl.Locale
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.onMain
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
|
|
@ -106,12 +107,13 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
||||||
while (maxAttempts > 0 && subtitleCount == newCount) {
|
while (maxAttempts > 0 && subtitleCount == newCount) {
|
||||||
maxAttempts--
|
maxAttempts--
|
||||||
delay(1500)
|
delay(1500)
|
||||||
item =
|
val base = BaseItem(api.userLibraryApi.getItem(itemId = it.itemId).content)
|
||||||
BaseItem.from(
|
currentItem =
|
||||||
api.userLibraryApi.getItem(itemId = it.itemId).content,
|
when (currentItem) {
|
||||||
api,
|
is PlaylistItem.Intro -> PlaylistItem.Intro(base)
|
||||||
)
|
is PlaylistItem.Media -> PlaylistItem.Media(base)
|
||||||
mediaSource = streamChoiceService.chooseSource(item.data, it)
|
}
|
||||||
|
mediaSource = streamChoiceService.chooseSource(currentItem.item.data, it)
|
||||||
if (mediaSource == null) {
|
if (mediaSource == null) {
|
||||||
// This shouldn't happen, but just in case
|
// This shouldn't happen, but just in case
|
||||||
showToast(
|
showToast(
|
||||||
|
|
@ -158,7 +160,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
this@downloadAndSwitchSubtitles.changeStreams(
|
this@downloadAndSwitchSubtitles.changeStreams(
|
||||||
item,
|
currentItem.item,
|
||||||
currentItemPlayback.value!!,
|
currentItemPlayback.value!!,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
newStream.index,
|
newStream.index,
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ object TrackSelectionUtils {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
val audioSelected =
|
val audioSelected =
|
||||||
if (audioIndex != null && supportsDirectPlay) {
|
if (audioIndex != null && audioIndex >= 0 && supportsDirectPlay) {
|
||||||
val indexToFind =
|
val indexToFind =
|
||||||
calculateIndexToFind(
|
calculateIndexToFind(
|
||||||
audioIndex,
|
audioIndex,
|
||||||
|
|
@ -141,7 +141,7 @@ object TrackSelectionUtils {
|
||||||
}
|
}
|
||||||
chosenTrack != null
|
chosenTrack != null
|
||||||
} else {
|
} else {
|
||||||
audioIndex == null
|
true
|
||||||
}
|
}
|
||||||
return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected)
|
return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.Chapter
|
||||||
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.ChapterCard
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HiddenFocusBox
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChapterRowOverlay(
|
||||||
|
player: Player,
|
||||||
|
controllerViewState: ControllerViewState,
|
||||||
|
chapters: List<Chapter>,
|
||||||
|
playlist: Playlist,
|
||||||
|
aspectRatio: Float,
|
||||||
|
onChangeState: (OverlayViewState) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val chapterInteractionSources =
|
||||||
|
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
val chapterIndex =
|
||||||
|
remember {
|
||||||
|
val position = player.currentPosition.milliseconds
|
||||||
|
val index =
|
||||||
|
chapters
|
||||||
|
.indexOfFirst { it.position > position }
|
||||||
|
.minus(1)
|
||||||
|
.let {
|
||||||
|
if (it < 0) {
|
||||||
|
// Didn't find a chapter, so it's either the first or last
|
||||||
|
if (position < chapters.first().position) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
chapters.lastIndex
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}.coerceIn(0, chapters.lastIndex)
|
||||||
|
index
|
||||||
|
}
|
||||||
|
val listState = rememberLazyListState(chapterIndex)
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val hiddenFocusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
HiddenFocusBox(hiddenFocusRequester) {
|
||||||
|
onChangeState.invoke(OverlayViewState.CONTROLLER)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.chapters),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
LazyRow(
|
||||||
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRestorer(focusRequester)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.hasFocus) {
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
}.focusProperties {
|
||||||
|
up = hiddenFocusRequester
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
itemsIndexed(chapters) { index, chapter ->
|
||||||
|
val interactionSource = chapterInteractionSources[index]
|
||||||
|
val isFocused =
|
||||||
|
interactionSource.collectIsFocusedAsState().value
|
||||||
|
LaunchedEffect(isFocused) {
|
||||||
|
if (isFocused) controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
ChapterCard(
|
||||||
|
name = chapter.name,
|
||||||
|
position = chapter.position,
|
||||||
|
imageUrl = chapter.imageUrl,
|
||||||
|
aspectRatio = aspectRatio,
|
||||||
|
onClick = {
|
||||||
|
player.seekTo(chapter.position.inWholeMilliseconds)
|
||||||
|
controllerViewState.hideControls()
|
||||||
|
},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.ifElse(
|
||||||
|
index == chapterIndex,
|
||||||
|
Modifier
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.bringIntoViewRequester(
|
||||||
|
bringIntoViewRequester,
|
||||||
|
),
|
||||||
|
).ifElse(
|
||||||
|
index == 0,
|
||||||
|
Modifier.focusProperties {
|
||||||
|
// Prevent scrolling left on first card to prevent moving down
|
||||||
|
left = FocusRequester.Cancel
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (playlist.hasNext()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.queue),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) onChangeState.invoke(OverlayViewState.QUEUE)
|
||||||
|
}.focusable(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||||
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.VisibilityThreshold
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.onFocusChanged
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.ui.getTimeFormatter
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||||
|
import java.time.LocalTime
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaybackController(
|
||||||
|
item: BaseItem?,
|
||||||
|
nextState: OverlayViewState?,
|
||||||
|
player: Player,
|
||||||
|
controllerViewState: ControllerViewState,
|
||||||
|
showPlay: Boolean,
|
||||||
|
previousEnabled: Boolean,
|
||||||
|
nextEnabled: Boolean,
|
||||||
|
seekEnabled: Boolean,
|
||||||
|
seekBack: Duration,
|
||||||
|
skipBackOnResume: Duration?,
|
||||||
|
seekForward: Duration,
|
||||||
|
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||||
|
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||||
|
onSeekBarChange: (Long) -> Unit,
|
||||||
|
currentSegment: MediaSegmentDto?,
|
||||||
|
onChangeState: (OverlayViewState) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Controller(
|
||||||
|
title = item?.title,
|
||||||
|
subtitle = item?.subtitleLong,
|
||||||
|
player = player,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
showPlay = showPlay,
|
||||||
|
previousEnabled = previousEnabled,
|
||||||
|
nextEnabled = nextEnabled,
|
||||||
|
seekEnabled = seekEnabled,
|
||||||
|
seekBack = seekBack,
|
||||||
|
skipBackOnResume = skipBackOnResume,
|
||||||
|
seekForward = seekForward,
|
||||||
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
||||||
|
onSeekProgress = onSeekBarChange,
|
||||||
|
seekBarInteractionSource = seekBarInteractionSource,
|
||||||
|
currentSegment = currentSegment,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
when (nextState) {
|
||||||
|
OverlayViewState.CHAPTERS -> {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.chapters),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = 16.dp, top = 0.dp)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) onChangeState.invoke(nextState)
|
||||||
|
}.focusable(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OverlayViewState.QUEUE -> {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.queue),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = 16.dp, top = 0.dp)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) onChangeState.invoke(nextState)
|
||||||
|
}.focusable(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val titleTextSize = 28.sp
|
||||||
|
internal val subtitleTextSize = 18.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper for the playback controls to show title and other information, plus the actual controls
|
||||||
|
*
|
||||||
|
* @see PlaybackControls
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Controller(
|
||||||
|
title: String?,
|
||||||
|
player: Player,
|
||||||
|
controllerViewState: ControllerViewState,
|
||||||
|
showPlay: Boolean,
|
||||||
|
previousEnabled: Boolean,
|
||||||
|
nextEnabled: Boolean,
|
||||||
|
seekEnabled: Boolean,
|
||||||
|
seekBack: Duration,
|
||||||
|
skipBackOnResume: Duration?,
|
||||||
|
seekForward: Duration,
|
||||||
|
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||||
|
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||||
|
onSeekProgress: (Long) -> Unit,
|
||||||
|
currentSegment: MediaSegmentDto?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
subtitle: String? = null,
|
||||||
|
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
||||||
|
val verticalOffset by animateDpAsState(
|
||||||
|
targetValue = if (seekBarFocused) (-32).dp else 0.dp,
|
||||||
|
label = "TitleBumpOffset",
|
||||||
|
animationSpec =
|
||||||
|
spring(
|
||||||
|
stiffness = Spring.StiffnessMediumLow,
|
||||||
|
visibilityThreshold = Dp.VisibilityThreshold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = 16.dp)
|
||||||
|
.offset(y = verticalOffset),
|
||||||
|
) {
|
||||||
|
title?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontSize = titleTextSize,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.MiddleEllipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(.75f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.End),
|
||||||
|
) {
|
||||||
|
subtitle?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontSize = subtitleTextSize,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.MiddleEllipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(.75f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var endTimeStr by remember { mutableStateOf("...") }
|
||||||
|
LaunchedEffect(player) {
|
||||||
|
while (isActive) {
|
||||||
|
val remaining =
|
||||||
|
(player.duration - player.currentPosition)
|
||||||
|
.div(player.playbackParameters.speed)
|
||||||
|
.toLong()
|
||||||
|
.milliseconds
|
||||||
|
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
|
||||||
|
endTimeStr = getTimeFormatter().format(endTime)
|
||||||
|
delay(1.seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Ends $endTimeStr",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(end = 32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlaybackControls(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
player = player,
|
||||||
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
onSeekProgress = {
|
||||||
|
onSeekProgress(it)
|
||||||
|
},
|
||||||
|
showPlay = showPlay,
|
||||||
|
previousEnabled = previousEnabled,
|
||||||
|
nextEnabled = nextEnabled,
|
||||||
|
seekEnabled = seekEnabled,
|
||||||
|
seekBarInteractionSource = seekBarInteractionSource,
|
||||||
|
seekBarIntervals = 16,
|
||||||
|
seekBack = seekBack,
|
||||||
|
seekForward = seekForward,
|
||||||
|
skipBackOnResume = skipBackOnResume,
|
||||||
|
currentSegment = currentSegment,
|
||||||
|
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
|
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||||
|
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
|
|
@ -69,6 +69,8 @@ import com.github.damontecres.wholphin.ui.components.Button
|
||||||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
||||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
|
||||||
import com.github.damontecres.wholphin.ui.seekBack
|
import com.github.damontecres.wholphin.ui.seekBack
|
||||||
import com.github.damontecres.wholphin.ui.seekForward
|
import com.github.damontecres.wholphin.ui.seekForward
|
||||||
import com.github.damontecres.wholphin.ui.skipStringRes
|
import com.github.damontecres.wholphin.ui.skipStringRes
|
||||||
|
|
@ -123,13 +125,10 @@ sealed interface PlaybackAction {
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaybackControls(
|
fun PlaybackControls(
|
||||||
playerControls: Player,
|
player: Player,
|
||||||
controllerViewState: ControllerViewState,
|
controllerViewState: ControllerViewState,
|
||||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||||
moreFocusRequester: FocusRequester,
|
|
||||||
captionFocusRequester: FocusRequester,
|
|
||||||
settingsFocusRequester: FocusRequester,
|
|
||||||
onSeekProgress: (Long) -> Unit,
|
onSeekProgress: (Long) -> Unit,
|
||||||
showPlay: Boolean,
|
showPlay: Boolean,
|
||||||
previousEnabled: Boolean,
|
previousEnabled: Boolean,
|
||||||
|
|
@ -164,7 +163,7 @@ fun PlaybackControls(
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
) {
|
) {
|
||||||
SeekBar(
|
SeekBar(
|
||||||
player = playerControls,
|
player = player,
|
||||||
controllerViewState = controllerViewState,
|
controllerViewState = controllerViewState,
|
||||||
onSeekProgress = onSeekProgress,
|
onSeekProgress = onSeekProgress,
|
||||||
interactionSource = seekBarInteractionSource,
|
interactionSource = seekBarInteractionSource,
|
||||||
|
|
@ -184,13 +183,12 @@ fun PlaybackControls(
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
LeftPlaybackButtons(
|
LeftPlaybackButtons(
|
||||||
moreFocusRequester = moreFocusRequester,
|
|
||||||
onControllerInteraction = onControllerInteraction,
|
onControllerInteraction = onControllerInteraction,
|
||||||
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
||||||
modifier = Modifier.align(Alignment.CenterStart),
|
modifier = Modifier.align(Alignment.CenterStart),
|
||||||
)
|
)
|
||||||
PlaybackButtons(
|
PlaybackButtons(
|
||||||
player = playerControls,
|
player = player,
|
||||||
initialFocusRequester = initialFocusRequester,
|
initialFocusRequester = initialFocusRequester,
|
||||||
onControllerInteraction = onControllerInteraction,
|
onControllerInteraction = onControllerInteraction,
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
|
@ -209,7 +207,7 @@ fun PlaybackControls(
|
||||||
TextButton(
|
TextButton(
|
||||||
stringRes = segment.type.skipStringRes,
|
stringRes = segment.type.skipStringRes,
|
||||||
onClick = {
|
onClick = {
|
||||||
playerControls.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -218,8 +216,6 @@ fun PlaybackControls(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
RightPlaybackButtons(
|
RightPlaybackButtons(
|
||||||
captionFocusRequester = captionFocusRequester,
|
|
||||||
settingsFocusRequester = settingsFocusRequester,
|
|
||||||
onControllerInteraction = onControllerInteraction,
|
onControllerInteraction = onControllerInteraction,
|
||||||
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
|
|
@ -301,7 +297,6 @@ val buttonSpacing = 12.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LeftPlaybackButtons(
|
fun LeftPlaybackButtons(
|
||||||
moreFocusRequester: FocusRequester,
|
|
||||||
onControllerInteraction: () -> Unit,
|
onControllerInteraction: () -> Unit,
|
||||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -310,24 +305,21 @@ fun LeftPlaybackButtons(
|
||||||
modifier = modifier.focusGroup(),
|
modifier = modifier.focusGroup(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
|
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
|
||||||
) {
|
) {
|
||||||
// More options
|
|
||||||
PlaybackButton(
|
PlaybackButton(
|
||||||
iconRes = R.drawable.baseline_more_vert_96,
|
iconRes = R.drawable.vector_settings,
|
||||||
onClick = {
|
onClick = {
|
||||||
onControllerInteraction.invoke()
|
onControllerInteraction.invoke()
|
||||||
onClickPlaybackDialogType.invoke(PlaybackDialogType.MORE)
|
onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS)
|
||||||
},
|
},
|
||||||
enabled = true,
|
enabled = true,
|
||||||
onControllerInteraction = onControllerInteraction,
|
onControllerInteraction = onControllerInteraction,
|
||||||
modifier = Modifier.focusRequester(moreFocusRequester),
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RightPlaybackButtons(
|
fun RightPlaybackButtons(
|
||||||
captionFocusRequester: FocusRequester,
|
|
||||||
settingsFocusRequester: FocusRequester,
|
|
||||||
onControllerInteraction: () -> Unit,
|
onControllerInteraction: () -> Unit,
|
||||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -345,18 +337,18 @@ fun RightPlaybackButtons(
|
||||||
onClickPlaybackDialogType.invoke(PlaybackDialogType.CAPTIONS)
|
onClickPlaybackDialogType.invoke(PlaybackDialogType.CAPTIONS)
|
||||||
},
|
},
|
||||||
onControllerInteraction = onControllerInteraction,
|
onControllerInteraction = onControllerInteraction,
|
||||||
modifier = Modifier.focusRequester(captionFocusRequester),
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
// Playback speed, etc
|
// Audio
|
||||||
PlaybackButton(
|
PlaybackFaButton(
|
||||||
iconRes = R.drawable.vector_settings,
|
enabled = true,
|
||||||
|
iconRes = R.string.fa_volume_high,
|
||||||
onClick = {
|
onClick = {
|
||||||
onControllerInteraction.invoke()
|
onControllerInteraction.invoke()
|
||||||
onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS)
|
onClickPlaybackDialogType.invoke(PlaybackDialogType.AUDIO)
|
||||||
},
|
},
|
||||||
enabled = true,
|
|
||||||
onControllerInteraction = onControllerInteraction,
|
onControllerInteraction = onControllerInteraction,
|
||||||
modifier = Modifier.focusRequester(settingsFocusRequester),
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -623,7 +615,7 @@ private fun ButtonPreview() {
|
||||||
onControllerInteraction = {},
|
onControllerInteraction = {},
|
||||||
)
|
)
|
||||||
PlaybackFaButton(
|
PlaybackFaButton(
|
||||||
iconRes = R.string.fa_shuffle,
|
iconRes = R.string.fa_volume_high,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
onControllerInteraction = {},
|
onControllerInteraction = {},
|
||||||
textColor = Color.Green,
|
textColor = Color.Green,
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.view.Display
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.ProvideTextStyle
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
|
import com.github.damontecres.wholphin.ui.formatBitrate
|
||||||
|
import com.github.damontecres.wholphin.ui.formatBytes
|
||||||
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.AnalyticsState
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import com.github.damontecres.wholphin.util.TrackSupport
|
||||||
|
import com.github.damontecres.wholphin.util.TrackSupportReason
|
||||||
|
import com.github.damontecres.wholphin.util.TrackType
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.HardwareAccelerationType
|
||||||
|
import org.jellyfin.sdk.model.api.MediaProtocol
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceType
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
import org.jellyfin.sdk.model.api.TranscodeReason
|
||||||
|
import org.jellyfin.sdk.model.api.TranscodingInfo
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaybackDebugOverlay(
|
||||||
|
analyticsState: AnalyticsState,
|
||||||
|
currentPlayback: CurrentPlayback?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val display =
|
||||||
|
remember(context) {
|
||||||
|
try {
|
||||||
|
val displayManager =
|
||||||
|
context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
|
||||||
|
displayManager?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val displayMode by produceState<String?>(null) {
|
||||||
|
while (isActive) {
|
||||||
|
value =
|
||||||
|
display?.mode?.let {
|
||||||
|
val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate)
|
||||||
|
"${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}"
|
||||||
|
}
|
||||||
|
delay(10.seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val textStyle =
|
||||||
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
|
||||||
|
val memoryUsed by produceState("") {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
while (isActive) {
|
||||||
|
val runtime = Runtime.getRuntime()
|
||||||
|
val total = runtime.totalMemory()
|
||||||
|
val free = runtime.freeMemory()
|
||||||
|
val used = total - free
|
||||||
|
val totalMemory = formatBytes(total)
|
||||||
|
val usedMemory = formatBytes(used)
|
||||||
|
value = "$usedMemory / $totalMemory"
|
||||||
|
delay(2.seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
|
||||||
|
) {
|
||||||
|
ProvideTextStyle(textStyle) {
|
||||||
|
SimpleTable(
|
||||||
|
remember(currentPlayback, displayMode) {
|
||||||
|
buildList {
|
||||||
|
add("Backend:" to currentPlayback?.backend?.toString())
|
||||||
|
add("Play method:" to currentPlayback?.playMethod?.serialName)
|
||||||
|
if (currentPlayback?.backend == PlayerBackend.EXO_PLAYER) {
|
||||||
|
add("Video Decoder:" to currentPlayback.videoDecoder)
|
||||||
|
add("Audio Decoder:" to (currentPlayback.audioDecoder ?: "Non-ExoPlayer"))
|
||||||
|
}
|
||||||
|
add("Display Mode: " to displayMode)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
keyWidth = 80.dp,
|
||||||
|
)
|
||||||
|
SimpleTable(
|
||||||
|
rows =
|
||||||
|
listOf(
|
||||||
|
"Bitrate: " to analyticsState.bitrate,
|
||||||
|
"Bitrate (est): " to analyticsState.bitrateEstimate,
|
||||||
|
"Dropped frames: " to analyticsState.droppedFrames,
|
||||||
|
"Memory" to memoryUsed,
|
||||||
|
),
|
||||||
|
keyWidth = 80.dp,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
currentPlayback?.transcodeInfo?.let { info ->
|
||||||
|
SimpleTable(
|
||||||
|
listOf(
|
||||||
|
"Reason:" to info.transcodeReasons.joinToString(", "),
|
||||||
|
"HW Accel:" to info.hardwareAccelerationType?.toString(),
|
||||||
|
"Container:" to info.container,
|
||||||
|
"Bitrate:" to info.bitrate?.let { formatBitrate(it) },
|
||||||
|
),
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
keyWidth = 64.dp,
|
||||||
|
)
|
||||||
|
SimpleTable(
|
||||||
|
listOf(
|
||||||
|
"Video:" to "${info.videoCodec}, ${info.width}x${info.height}",
|
||||||
|
"Video Direct:" to info.isVideoDirect.toString(),
|
||||||
|
"Audio:" to "${info.audioCodec}, ch=${info.audioChannels}",
|
||||||
|
"Audio Direct:" to info.isAudioDirect.toString(),
|
||||||
|
),
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
keyWidth = 64.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentPlayback?.tracks?.letNotEmpty {
|
||||||
|
PlaybackTrackInfo(
|
||||||
|
trackSupport = it,
|
||||||
|
textStyle = textStyle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SimpleTable(
|
||||||
|
rows: List<Pair<String, Any?>>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
keyWidth: Dp = 100.dp,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
rows.forEach {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = it.first,
|
||||||
|
modifier = Modifier.width(keyWidth),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = it.second.toString(),
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@PreviewTvSpec
|
||||||
|
@Composable
|
||||||
|
fun PlaybackDebugOverlayPreview() {
|
||||||
|
val currentPlayback =
|
||||||
|
CurrentPlayback(
|
||||||
|
item =
|
||||||
|
BaseItem(
|
||||||
|
data =
|
||||||
|
BaseItemDto(
|
||||||
|
id = UUID.randomUUID(),
|
||||||
|
type = BaseItemKind.EPISODE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backend = PlayerBackend.EXO_PLAYER,
|
||||||
|
playMethod = PlayMethod.TRANSCODE,
|
||||||
|
playSessionId = "123",
|
||||||
|
liveStreamId = "123",
|
||||||
|
videoDecoder = "video.decoder.name",
|
||||||
|
audioDecoder = null, // "audio.decoder.name",
|
||||||
|
mediaSourceInfo =
|
||||||
|
MediaSourceInfo(
|
||||||
|
protocol = MediaProtocol.HTTP,
|
||||||
|
type = MediaSourceType.DEFAULT,
|
||||||
|
isRemote = false,
|
||||||
|
readAtNativeFramerate = true,
|
||||||
|
ignoreDts = true,
|
||||||
|
ignoreIndex = true,
|
||||||
|
genPtsInput = false,
|
||||||
|
supportsTranscoding = true,
|
||||||
|
supportsDirectStream = true,
|
||||||
|
supportsDirectPlay = true,
|
||||||
|
isInfiniteStream = false,
|
||||||
|
requiresOpening = false,
|
||||||
|
requiresClosing = false,
|
||||||
|
requiresLooping = false,
|
||||||
|
supportsProbing = true,
|
||||||
|
transcodingSubProtocol = MediaStreamProtocol.HTTP,
|
||||||
|
hasSegments = false,
|
||||||
|
),
|
||||||
|
transcodeInfo =
|
||||||
|
TranscodingInfo(
|
||||||
|
videoCodec = "h264",
|
||||||
|
audioCodec = "aac",
|
||||||
|
container = "HLS",
|
||||||
|
width = 1080,
|
||||||
|
height = 1920,
|
||||||
|
isVideoDirect = false,
|
||||||
|
isAudioDirect = false,
|
||||||
|
transcodeReasons =
|
||||||
|
listOf(
|
||||||
|
TranscodeReason.VIDEO_PROFILE_NOT_SUPPORTED,
|
||||||
|
TranscodeReason.AUDIO_CHANNELS_NOT_SUPPORTED,
|
||||||
|
),
|
||||||
|
hardwareAccelerationType = HardwareAccelerationType.NONE,
|
||||||
|
),
|
||||||
|
tracks =
|
||||||
|
listOf(
|
||||||
|
TrackSupport(
|
||||||
|
id = "0",
|
||||||
|
type = TrackType.VIDEO,
|
||||||
|
supported = TrackSupportReason.EXCEEDS_CAPABILITIES,
|
||||||
|
selected = true,
|
||||||
|
labels = listOf(),
|
||||||
|
codecs = "avc1",
|
||||||
|
format = Format.Builder().build(),
|
||||||
|
),
|
||||||
|
TrackSupport(
|
||||||
|
id = "0",
|
||||||
|
type = TrackType.AUDIO,
|
||||||
|
supported = TrackSupportReason.HANDLED,
|
||||||
|
selected = true,
|
||||||
|
labels = listOf(),
|
||||||
|
codecs = "ac3",
|
||||||
|
format = Format.Builder().build(),
|
||||||
|
),
|
||||||
|
) +
|
||||||
|
List(10) {
|
||||||
|
TrackSupport(
|
||||||
|
id = "0",
|
||||||
|
type = TrackType.TEXT,
|
||||||
|
supported = TrackSupportReason.HANDLED,
|
||||||
|
selected = false,
|
||||||
|
labels = listOf(),
|
||||||
|
codecs = "srt",
|
||||||
|
format = Format.Builder().build(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
WholphinTheme {
|
||||||
|
PlaybackDebugOverlay(
|
||||||
|
analyticsState = AnalyticsState(),
|
||||||
|
currentPlayback = currentPlayback,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,399 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.VisibilityThreshold
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.animation.slideIn
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOut
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
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.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.Chapter
|
||||||
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
|
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
||||||
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.AnalyticsState
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||||
|
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The overlay during playback showing controls, seek preview image, debug info, etc
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PlaybackOverlay(
|
||||||
|
item: BaseItem?,
|
||||||
|
chapters: List<Chapter>,
|
||||||
|
player: Player,
|
||||||
|
controllerViewState: ControllerViewState,
|
||||||
|
showPlay: Boolean,
|
||||||
|
showClock: Boolean,
|
||||||
|
previousEnabled: Boolean,
|
||||||
|
nextEnabled: Boolean,
|
||||||
|
seekEnabled: Boolean,
|
||||||
|
seekBack: Duration,
|
||||||
|
skipBackOnResume: Duration?,
|
||||||
|
seekForward: Duration,
|
||||||
|
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||||
|
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||||
|
onSeekBarChange: (Long) -> Unit,
|
||||||
|
showDebugInfo: Boolean,
|
||||||
|
currentPlayback: CurrentPlayback?,
|
||||||
|
currentSegment: MediaSegmentDto?,
|
||||||
|
analyticsState: AnalyticsState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
trickplayInfo: TrickplayInfo? = null,
|
||||||
|
trickplayUrlFor: (Int) -> String? = { null },
|
||||||
|
playlist: Playlist = Playlist(listOf(), 0),
|
||||||
|
onClickPlaylist: (BaseItem) -> Unit = {},
|
||||||
|
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
||||||
|
// Will be used for preview/trick play images
|
||||||
|
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(player.currentPosition) }
|
||||||
|
var seekProgressPercent = (seekProgressMs.toDouble() / player.duration).toFloat()
|
||||||
|
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
val titleHeight =
|
||||||
|
remember(item?.title) {
|
||||||
|
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
|
||||||
|
}
|
||||||
|
val subtitleHeight =
|
||||||
|
remember(item?.subtitleLong) {
|
||||||
|
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will be calculated after composition
|
||||||
|
var controllerHeight by remember { mutableStateOf(0.dp) }
|
||||||
|
var state by remember(controllerViewState.controlsVisible) {
|
||||||
|
mutableStateOf(if (controllerViewState.controlsVisible) OverlayViewState.CONTROLLER else OverlayViewState.HIDDEN)
|
||||||
|
}
|
||||||
|
val onChangeState = { newState: OverlayViewState ->
|
||||||
|
state = newState
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier,
|
||||||
|
contentAlignment = Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = controllerViewState.controlsVisible && !showDebugInfo,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier.matchParentSize(),
|
||||||
|
) {
|
||||||
|
// Background scrim for OSD readability
|
||||||
|
val scrimBrush =
|
||||||
|
remember {
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors =
|
||||||
|
listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
Color.Black.copy(alpha = 0.5f),
|
||||||
|
Color.Black.copy(alpha = 0.80f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(scrimBrush),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = state,
|
||||||
|
label = "controls transition",
|
||||||
|
transitionSpec = {
|
||||||
|
if (targetState.ordinal > initialState.ordinal) {
|
||||||
|
// Moving down, so move content up
|
||||||
|
(slideInVertically { it / 2 } + fadeIn()).togetherWith(slideOutVertically { -it / 2 } + fadeOut())
|
||||||
|
} else {
|
||||||
|
// Moving up
|
||||||
|
(slideInVertically { -it / 2 } + fadeIn()).togetherWith(slideOutVertically { it / 2 } + fadeOut())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { targetState ->
|
||||||
|
when (targetState) {
|
||||||
|
OverlayViewState.HIDDEN -> {
|
||||||
|
// Necessary so the bounds for animation are full width
|
||||||
|
Box(Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
OverlayViewState.CONTROLLER -> {
|
||||||
|
if (seekBarFocused) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
seekProgressPercent =
|
||||||
|
(player.currentPosition.toFloat() / player.duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val nextState =
|
||||||
|
remember(chapters, playlist) {
|
||||||
|
if (chapters.isNotEmpty()) {
|
||||||
|
OverlayViewState.CHAPTERS
|
||||||
|
} else if (playlist.hasNext()) {
|
||||||
|
OverlayViewState.QUEUE
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlaybackController(
|
||||||
|
item = item,
|
||||||
|
nextState = nextState,
|
||||||
|
player = player,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
showPlay = showPlay,
|
||||||
|
previousEnabled = previousEnabled,
|
||||||
|
nextEnabled = nextEnabled,
|
||||||
|
seekEnabled = seekEnabled,
|
||||||
|
seekBack = seekBack,
|
||||||
|
skipBackOnResume = skipBackOnResume,
|
||||||
|
seekForward = seekForward,
|
||||||
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
onClickPlaybackDialogType = onClickPlaybackDialogType,
|
||||||
|
onSeekBarChange = {
|
||||||
|
onSeekBarChange(it)
|
||||||
|
seekProgressMs = it
|
||||||
|
},
|
||||||
|
currentSegment = currentSegment,
|
||||||
|
onChangeState = onChangeState,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
.onGloballyPositioned {
|
||||||
|
controllerHeight = with(density) { it.size.height.toDp() }
|
||||||
|
},
|
||||||
|
seekBarInteractionSource = seekBarInteractionSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OverlayViewState.CHAPTERS -> {
|
||||||
|
if (chapters.isNotEmpty()) {
|
||||||
|
ChapterRowOverlay(
|
||||||
|
player = player,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
chapters = chapters,
|
||||||
|
playlist = playlist,
|
||||||
|
aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE,
|
||||||
|
onChangeState = onChangeState,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OverlayViewState.QUEUE -> {
|
||||||
|
if (playlist.hasNext()) {
|
||||||
|
QueueRowOverlay(
|
||||||
|
playlist = playlist,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
nextState =
|
||||||
|
remember(chapters) {
|
||||||
|
if (chapters.isNotEmpty()) {
|
||||||
|
OverlayViewState.CHAPTERS
|
||||||
|
} else {
|
||||||
|
OverlayViewState.CONTROLLER
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChangeState = onChangeState,
|
||||||
|
onClickPlaylist = onClickPlaylist,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trickplay
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
|
||||||
|
enter =
|
||||||
|
expandVertically(
|
||||||
|
spring(
|
||||||
|
stiffness = Spring.StiffnessMedium,
|
||||||
|
visibilityThreshold = IntSize.VisibilityThreshold,
|
||||||
|
),
|
||||||
|
) + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.fillMaxWidth(.95f),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.offsetByPercent(
|
||||||
|
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
|
||||||
|
).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
|
||||||
|
) {
|
||||||
|
if (trickplayInfo != null) {
|
||||||
|
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
|
||||||
|
val index =
|
||||||
|
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
|
||||||
|
val imageUrl = remember(index) { trickplayUrlFor(index) }
|
||||||
|
|
||||||
|
if (imageUrl != null) {
|
||||||
|
SeekPreviewImage(
|
||||||
|
modifier = Modifier,
|
||||||
|
previewImageUrl = imageUrl,
|
||||||
|
seekProgressMs = seekProgressMs,
|
||||||
|
trickPlayInfo = trickplayInfo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = (seekProgressMs / 1000L).seconds.toString(),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.background(
|
||||||
|
Color.Black.copy(alpha = 0.6f),
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top
|
||||||
|
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
|
||||||
|
enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(),
|
||||||
|
exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopStart),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = logoImageUrl,
|
||||||
|
contentDescription = "Logo",
|
||||||
|
alignment = Alignment.TopStart,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(width = 240.dp, height = 120.dp)
|
||||||
|
.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !showDebugInfo && showClock && controllerViewState.controlsVisible,
|
||||||
|
enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(),
|
||||||
|
exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd),
|
||||||
|
) {
|
||||||
|
TimeDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug overlay
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showDebugInfo && controllerViewState.controlsVisible,
|
||||||
|
enter = slideInVertically() + fadeIn(),
|
||||||
|
exit = slideOutVertically() + fadeOut(),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopStart),
|
||||||
|
) {
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val height =
|
||||||
|
remember(
|
||||||
|
configuration,
|
||||||
|
controllerHeight,
|
||||||
|
) { configuration.screenHeightDp.dp - controllerHeight }
|
||||||
|
PlaybackDebugOverlay(
|
||||||
|
analyticsState = analyticsState,
|
||||||
|
currentPlayback = currentPlayback,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.heightIn(max = height)
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
.background(AppColors.TransparentBlack50)
|
||||||
|
.padding(8.dp)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.hasFocus) {
|
||||||
|
// If Debug overlay gains focus, do not hide the controls
|
||||||
|
controllerViewState.pulseControls(Long.MAX_VALUE)
|
||||||
|
} else {
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The view state of the overlay
|
||||||
|
*/
|
||||||
|
enum class OverlayViewState {
|
||||||
|
HIDDEN,
|
||||||
|
CONTROLLER,
|
||||||
|
CHAPTERS,
|
||||||
|
QUEUE,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.focusProperties
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.ProvideTextStyle
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.ui.components.Button
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
import com.github.damontecres.wholphin.util.TrackSupport
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug info about the current playback tracks
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PlaybackTrackInfo(
|
||||||
|
trackSupport: List<TrackSupport>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
textStyle: TextStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
|
) {
|
||||||
|
val selectedTracks = remember(trackSupport) { trackSupport.filter { it.selected } }
|
||||||
|
val selectedWeight = .5f
|
||||||
|
val weights = listOf(.25f, .4f, .5f, 1f, 1f)
|
||||||
|
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val focusRequesters =
|
||||||
|
remember(trackSupport.size) { List(trackSupport.size) { FocusRequester() } }
|
||||||
|
val showButtonFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val texts =
|
||||||
|
listOf(
|
||||||
|
"ID",
|
||||||
|
"Type",
|
||||||
|
"Codec",
|
||||||
|
"Supported",
|
||||||
|
"Labels",
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.weight(selectedWeight),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Selected",
|
||||||
|
style = textStyle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
texts.forEachIndexed { index, text ->
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
modifier = Modifier.weight(weights[index]),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = textStyle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val tracks = if (expanded) trackSupport else selectedTracks
|
||||||
|
itemsIndexed(tracks) { index, track ->
|
||||||
|
ProvideTextStyle(textStyle) {
|
||||||
|
TrackSupportRow(
|
||||||
|
track = track,
|
||||||
|
selectedWeight = selectedWeight,
|
||||||
|
weights = weights,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(focusRequesters[index])
|
||||||
|
.ifElse(
|
||||||
|
index == tracks.lastIndex,
|
||||||
|
Modifier.focusProperties {
|
||||||
|
down = showButtonFocusRequester
|
||||||
|
right = showButtonFocusRequester
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trackSupport.size > selectedTracks.size) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val height =
|
||||||
|
remember(density, textStyle) {
|
||||||
|
with(density) {
|
||||||
|
textStyle.fontSize.toDp() * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth()) {
|
||||||
|
Button(
|
||||||
|
onClick = { expanded = !expanded },
|
||||||
|
shape =
|
||||||
|
ClickableSurfaceDefaults.shape(
|
||||||
|
shape = RoundedCornerShape(25),
|
||||||
|
),
|
||||||
|
contentHeight = height,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.CenterEnd)
|
||||||
|
.focusRequester(showButtonFocusRequester)
|
||||||
|
.focusProperties {
|
||||||
|
up = focusRequesters[0]
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val text =
|
||||||
|
if (expanded) {
|
||||||
|
stringResource(R.string.hide)
|
||||||
|
} else {
|
||||||
|
stringResource(
|
||||||
|
R.string.show_more,
|
||||||
|
trackSupport.size - selectedTracks.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = textStyle.fontSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TrackSupportRow(
|
||||||
|
track: TrackSupport,
|
||||||
|
selectedWeight: Float,
|
||||||
|
weights: List<Float>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = {},
|
||||||
|
modifier = modifier,
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RectangleShape),
|
||||||
|
colors =
|
||||||
|
ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = .25f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier.ifElse(
|
||||||
|
track.selected,
|
||||||
|
Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val texts =
|
||||||
|
listOf(
|
||||||
|
track.id ?: "",
|
||||||
|
track.type.name,
|
||||||
|
track.codecs ?: "",
|
||||||
|
track.supported.name,
|
||||||
|
track.labels.joinToString(", "),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.weight(selectedWeight),
|
||||||
|
) {
|
||||||
|
if (track.selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "-",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
texts.forEachIndexed { index, text ->
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
modifier = Modifier.weight(weights[index]),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HiddenFocusBox
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QueueRowOverlay(
|
||||||
|
playlist: Playlist,
|
||||||
|
controllerViewState: ControllerViewState,
|
||||||
|
nextState: OverlayViewState,
|
||||||
|
onChangeState: (OverlayViewState) -> Unit,
|
||||||
|
onClickPlaylist: (BaseItem) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val items = remember { playlist.upcomingItems() }
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val hiddenFocusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
HiddenFocusBox(hiddenFocusRequester) {
|
||||||
|
onChangeState.invoke(nextState)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.queue),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
LazyRow(
|
||||||
|
contentPadding =
|
||||||
|
PaddingValues(
|
||||||
|
horizontal = 16.dp,
|
||||||
|
vertical = 8.dp,
|
||||||
|
),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRestorer(focusRequester)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.hasFocus) {
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
itemsIndexed(items) { index, item ->
|
||||||
|
val interactionSource =
|
||||||
|
remember { MutableInteractionSource() }
|
||||||
|
val isFocused =
|
||||||
|
interactionSource.collectIsFocusedAsState().value
|
||||||
|
LaunchedEffect(isFocused) {
|
||||||
|
if (isFocused) controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
SeasonCard(
|
||||||
|
item = item.item,
|
||||||
|
onClick = {
|
||||||
|
onClickPlaylist.invoke(item.item)
|
||||||
|
controllerViewState.hideControls()
|
||||||
|
},
|
||||||
|
onLongClick = {},
|
||||||
|
imageHeight = 140.dp,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier =
|
||||||
|
Modifier.ifElse(
|
||||||
|
index == 0,
|
||||||
|
Modifier.focusRequester(focusRequester),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Modified from https://github.com/android/tv-samples
|
* Modified from https://github.com/android/tv-samples
|
||||||
|
|
@ -46,6 +46,8 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.calculateSeekAccelerationMultiplier
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||||
|
|
||||||
import androidx.annotation.FloatRange
|
import androidx.annotation.FloatRange
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback.overlay
|
||||||
|
|
||||||
import androidx.compose.animation.animateColor
|
import androidx.compose.animation.animateColor
|
||||||
import androidx.compose.animation.core.InfiniteRepeatableSpec
|
import androidx.compose.animation.core.InfiniteRepeatableSpec
|
||||||
|
|
@ -36,6 +36,7 @@ enum class PreferenceScreenOption {
|
||||||
EXO_PLAYER,
|
EXO_PLAYER,
|
||||||
MPV,
|
MPV,
|
||||||
SCREENSAVER,
|
SCREENSAVER,
|
||||||
|
SKIP_SEGMENTS,
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.MpvPreferences
|
import com.github.damontecres.wholphin.preferences.MpvPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
||||||
|
import com.github.damontecres.wholphin.preferences.SkipSegmentPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.advancedPreferences
|
import com.github.damontecres.wholphin.preferences.advancedPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.basicPreferences
|
import com.github.damontecres.wholphin.preferences.basicPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.screensaverPreferences
|
import com.github.damontecres.wholphin.preferences.screensaverPreferences
|
||||||
|
|
@ -142,6 +143,7 @@ fun PreferencesContent(
|
||||||
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
|
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
|
||||||
PreferenceScreenOption.MPV -> MpvPreferences
|
PreferenceScreenOption.MPV -> MpvPreferences
|
||||||
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
|
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
|
||||||
|
PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences
|
||||||
}
|
}
|
||||||
val screenTitle =
|
val screenTitle =
|
||||||
when (preferenceScreenOption) {
|
when (preferenceScreenOption) {
|
||||||
|
|
@ -150,6 +152,7 @@ fun PreferencesContent(
|
||||||
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
|
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
|
||||||
PreferenceScreenOption.MPV -> R.string.mpv_options
|
PreferenceScreenOption.MPV -> R.string.mpv_options
|
||||||
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
|
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
|
||||||
|
PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior
|
||||||
}
|
}
|
||||||
|
|
||||||
var visible by remember { mutableStateOf(false) }
|
var visible by remember { mutableStateOf(false) }
|
||||||
|
|
@ -707,6 +710,7 @@ fun PreferencesPage(
|
||||||
PreferenceScreenOption.EXO_PLAYER,
|
PreferenceScreenOption.EXO_PLAYER,
|
||||||
PreferenceScreenOption.MPV,
|
PreferenceScreenOption.MPV,
|
||||||
PreferenceScreenOption.SCREENSAVER,
|
PreferenceScreenOption.SCREENSAVER,
|
||||||
|
PreferenceScreenOption.SKIP_SEGMENTS,
|
||||||
-> {
|
-> {
|
||||||
PreferencesContent(
|
PreferencesContent(
|
||||||
initialPreferences,
|
initialPreferences,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
|
@ -72,6 +73,16 @@ fun PinEntry(
|
||||||
Key.DirectionRight -> "R"
|
Key.DirectionRight -> "R"
|
||||||
Key.DirectionDown -> "D"
|
Key.DirectionDown -> "D"
|
||||||
Key.DirectionLeft -> "L"
|
Key.DirectionLeft -> "L"
|
||||||
|
Key.Zero, Key.NumPad0 -> "0"
|
||||||
|
Key.One, Key.NumPad1 -> "1"
|
||||||
|
Key.Two, Key.NumPad2 -> "2"
|
||||||
|
Key.Three, Key.NumPad3 -> "3"
|
||||||
|
Key.Four, Key.NumPad4 -> "4"
|
||||||
|
Key.Five, Key.NumPad5 -> "5"
|
||||||
|
Key.Six, Key.NumPad6 -> "6"
|
||||||
|
Key.Seven, Key.NumPad7 -> "7"
|
||||||
|
Key.Eight, Key.NumPad8 -> "8"
|
||||||
|
Key.Nine, Key.NumPad9 -> "9"
|
||||||
else -> return@onKeyEvent false
|
else -> return@onKeyEvent false
|
||||||
}
|
}
|
||||||
onTextChange.invoke(str)
|
onTextChange.invoke(str)
|
||||||
|
|
@ -122,6 +133,16 @@ fun PinEntryCreate(
|
||||||
Key.DirectionRight -> "R"
|
Key.DirectionRight -> "R"
|
||||||
Key.DirectionDown -> "D"
|
Key.DirectionDown -> "D"
|
||||||
Key.DirectionLeft -> "L"
|
Key.DirectionLeft -> "L"
|
||||||
|
Key.Zero, Key.NumPad0 -> "0"
|
||||||
|
Key.One, Key.NumPad1 -> "1"
|
||||||
|
Key.Two, Key.NumPad2 -> "2"
|
||||||
|
Key.Three, Key.NumPad3 -> "3"
|
||||||
|
Key.Four, Key.NumPad4 -> "4"
|
||||||
|
Key.Five, Key.NumPad5 -> "5"
|
||||||
|
Key.Six, Key.NumPad6 -> "6"
|
||||||
|
Key.Seven, Key.NumPad7 -> "7"
|
||||||
|
Key.Eight, Key.NumPad8 -> "8"
|
||||||
|
Key.Nine, Key.NumPad9 -> "9"
|
||||||
else -> return@onKeyEvent false
|
else -> return@onKeyEvent false
|
||||||
}
|
}
|
||||||
onTextChange.invoke(str)
|
onTextChange.invoke(str)
|
||||||
|
|
@ -154,6 +175,7 @@ fun PinEntryCreate(
|
||||||
@Composable
|
@Composable
|
||||||
fun PinArrowRow(modifier: Modifier = Modifier) {
|
fun PinArrowRow(modifier: Modifier = Modifier) {
|
||||||
Row(
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -167,6 +189,12 @@ fun PinArrowRow(modifier: Modifier = Modifier) {
|
||||||
fontFamily = FontAwesome,
|
fontFamily = FontAwesome,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pin_digits_hint),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -31,12 +24,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
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 androidx.compose.ui.window.DialogProperties
|
|
||||||
import androidx.tv.material3.Border
|
import androidx.tv.material3.Border
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.IconButtonDefaults
|
|
||||||
import androidx.tv.material3.ListItem
|
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
@ -44,9 +34,6 @@ import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
|
||||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -62,120 +49,6 @@ sealed interface ServerConnectionStatus {
|
||||||
) : ServerConnectionStatus
|
) : ServerConnectionStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Display a list of servers plus option to add a new one
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ServerList(
|
|
||||||
servers: List<JellyfinServer>,
|
|
||||||
connectionStatus: Map<UUID, ServerConnectionStatus>,
|
|
||||||
onSwitchServer: (JellyfinServer) -> Unit,
|
|
||||||
onTestServer: (JellyfinServer) -> Unit,
|
|
||||||
onAddServer: () -> Unit,
|
|
||||||
onRemoveServer: (JellyfinServer) -> Unit,
|
|
||||||
allowAdd: Boolean,
|
|
||||||
allowDelete: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) }
|
|
||||||
|
|
||||||
LazyColumn(modifier = modifier) {
|
|
||||||
items(servers) { server ->
|
|
||||||
val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending
|
|
||||||
ListItem(
|
|
||||||
enabled = true,
|
|
||||||
selected = false,
|
|
||||||
headlineContent = { Text(text = server.name?.ifBlank { null } ?: server.url) },
|
|
||||||
supportingContent = { if (server.name.isNotNullOrBlank()) Text(text = server.url) },
|
|
||||||
leadingContent = {
|
|
||||||
when (status) {
|
|
||||||
is ServerConnectionStatus.Success -> {}
|
|
||||||
|
|
||||||
ServerConnectionStatus.Pending -> {
|
|
||||||
CircularProgress(
|
|
||||||
Modifier.size(IconButtonDefaults.MediumIconSize),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is ServerConnectionStatus.Error -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Warning,
|
|
||||||
contentDescription = status.message,
|
|
||||||
tint = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
when (status) {
|
|
||||||
is ServerConnectionStatus.Success -> {
|
|
||||||
onSwitchServer.invoke(server)
|
|
||||||
}
|
|
||||||
|
|
||||||
ServerConnectionStatus.Pending -> {}
|
|
||||||
|
|
||||||
is ServerConnectionStatus.Error -> {
|
|
||||||
onTestServer.invoke(server)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongClick = {
|
|
||||||
if (allowDelete) {
|
|
||||||
showDeleteDialog = server
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (allowAdd) {
|
|
||||||
item {
|
|
||||||
HorizontalDivider()
|
|
||||||
ListItem(
|
|
||||||
enabled = true,
|
|
||||||
selected = false,
|
|
||||||
headlineContent = { Text(text = stringResource(R.string.add_server)) },
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Add,
|
|
||||||
tint = Color.Green.copy(alpha = .8f),
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { onAddServer.invoke() },
|
|
||||||
modifier = Modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showDeleteDialog?.let { server ->
|
|
||||||
DialogPopup(
|
|
||||||
showDialog = allowDelete,
|
|
||||||
title = server.name ?: server.url,
|
|
||||||
dialogItems =
|
|
||||||
listOf(
|
|
||||||
DialogItem(
|
|
||||||
stringResource(R.string.switch_servers),
|
|
||||||
R.string.fa_arrow_left_arrow_right,
|
|
||||||
) {
|
|
||||||
onSwitchServer.invoke(server)
|
|
||||||
},
|
|
||||||
DialogItem(
|
|
||||||
stringResource(R.string.delete),
|
|
||||||
Icons.Default.Delete,
|
|
||||||
Color.Red.copy(alpha = .8f),
|
|
||||||
) {
|
|
||||||
onRemoveServer.invoke(server)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
onDismissRequest = { showDeleteDialog = null },
|
|
||||||
dismissOnClick = true,
|
|
||||||
waitToLoad = true,
|
|
||||||
properties = DialogProperties(),
|
|
||||||
elevation = 5.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a consistent color for a UUID
|
* Generate a consistent color for a UUID
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue