diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml index 34dc317b..cbb287b5 100644 --- a/.github/ISSUE_TEMPLATE/01-playback.yml +++ b/.github/ISSUE_TEMPLATE/01-playback.yml @@ -29,6 +29,7 @@ body: options: - ExoPlayer (default) - MPV + - External App (please specify) validations: required: true - type: input diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b50a58b8..348b6bf1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,7 @@ concurrency: env: BRANCH_NAME: ${{ github.ref_name }} - GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'" jobs: build: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bde4e1ef..8607abd8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,7 +9,7 @@ defaults: env: BUILD_DIRS_ARTIFACT: build-dirs - GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'" jobs: pre-commit: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb9d21f1..4f4fc429 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ defaults: env: 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 }} jobs: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 2026c9ee..a2338108 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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. +#### 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 is split into several packages: @@ -43,21 +51,22 @@ Code is split into several packages: - `ui` - User interface code and ViewModels - `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`. -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 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 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 + +#### 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 56c80b90..7f5bbcec 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,6 @@ +import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ProductFlavor +import com.android.build.api.variant.FilterConfiguration import com.google.protobuf.gradle.id import com.mikepenz.aboutlibraries.plugin.DuplicateMode import com.mikepenz.aboutlibraries.plugin.DuplicateRule @@ -37,7 +39,15 @@ val gitDescribe = .standardOutput.asText .getOrElse("v0.0.0") -android { +kotlin { + compilerOptions { + languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3 + jvmTarget = JvmTarget.JVM_11 + javaParameters = true + } +} + +configure { namespace = "com.github.damontecres.wholphin" compileSdk = 36 @@ -98,18 +108,6 @@ android { isDebuggable = true 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" productFlavors { @@ -148,20 +146,11 @@ android { targetCompatibility = JavaVersion.VERSION_11 isCoreLibraryDesugaringEnabled = true } - kotlin { - compilerOptions { - languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3 - jvmTarget = JvmTarget.JVM_11 - javaParameters = true - } - } + buildFeatures { buildConfig = true compose = true } - room { - schemaDirectory("$projectDir/schemas") - } splits { 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 { protoc { artifact = "com.google.protobuf:protoc:${libs.protobuf.kotlin.lite.get().version}" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e75ab500..51377cb8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -45,7 +45,8 @@ android:theme="@style/Theme.Wholphin" android:name=".WholphinApplication" android:usesCleartextTraffic="true" - android:networkSecurityConfig="@xml/network_security_config"> + android:networkSecurityConfig="@xml/network_security_config" + android:largeHeap="true"> @@ -153,12 +154,10 @@ class MainActivity : AppCompatActivity() { if (backStackStr != null) { Timber.d("Restoring back stack") var backStack = json.decodeFromString>(backStackStr) - if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) { + + if (!playExternalViewModel.launched.value) { val lastDest = backStack.lastOrNull() - if (lastDest is Destination.Playback || - lastDest is Destination.PlaybackList || - lastDest is Destination.Slideshow - ) { + if (lastDest.isPlayback) { Timber.v("Restoring back stack with playback") backStack = backStack.toMutableList().apply { removeAt(lastIndex) } } @@ -220,14 +219,7 @@ class MainActivity : AppCompatActivity() { signInAuto = appPreferences.signInAutomatically } CoilConfig( - diskCacheSizeBytes = - appPreferences.advancedPreferences.imageDiskCacheSizeBytes.let { - if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) { - AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT - } else { - it - } - }, + prefs = appPreferences, okHttpClient = okHttpClient, debugLogging = false, enableCache = true, @@ -279,6 +271,14 @@ class MainActivity : AppCompatActivity() { super.onRestart() Timber.d("onRestart") 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() { @@ -479,3 +479,9 @@ class MainActivityViewModel } } } + +private val Destination?.isPlayback: Boolean + get() = + this is Destination.Playback || + this is Destination.PlaybackList || + this is Destination.Slideshow diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index 0794da67..4de2ab2f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -85,7 +85,7 @@ fun MainContent( is SetupDestination.UserList -> { SwitchUserContent( - currentServer = key.server, + server = key.server, Modifier.fillMaxSize(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt index 1a350905..502444e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -23,6 +23,8 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences 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.launchDefault 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 kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first +import okhttp3.OkHttpClient import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject @@ -48,6 +51,10 @@ class WholphinDreamService : @Inject lateinit var preferencesDataStore: DataStore + @AuthOkHttpClient + @Inject + lateinit var okHttpClient: OkHttpClient + private val lifecycleRegistry = LifecycleRegistry(this) private val savedStateRegistryController = @@ -88,6 +95,12 @@ class WholphinDreamService : preferencesDataStore.data.collectLatest { prefs = it } } prefs?.let { prefs -> + CoilConfig( + prefs = prefs, + okHttpClient = okHttpClient, + debugLogging = false, + enableCache = true, + ) WholphinTheme(appThemeColors = prefs.interfacePreferences.appThemeColors) { ProvideLocalClock { val screensaverPrefs = prefs.interfacePreferences.screensaverPreference diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt index ede44e08..ce73e6eb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt @@ -15,7 +15,11 @@ sealed interface ExtrasItem { val parentId: UUID val type: ExtraType 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 @@ -24,11 +28,16 @@ sealed interface ExtrasItem { override val parentId: UUID, override val type: ExtraType, val items: List, + override val imageUrl: String?, + override val title: String, + override val subtitle: String, + override val isPlayed: Boolean, ) : ExtrasItem { override val destination: Destination = 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 type: ExtraType, val item: BaseItem, + override val imageUrl: String?, + override val title: String, + override val subtitle: String?, ) : ExtrasItem { override val destination: Destination = Destination.Playback( 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 } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 2d786ed8..7e32f2e5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -73,7 +73,7 @@ class ServerRepository suspend fun changeUser( server: JellyfinServer, user: JellyfinUser, - ): CurrentUser? = + ): CurrentUser = withContext(ioDispatcher) { if (server.id != user.serverId) { throw IllegalStateException("User is not part of the server") @@ -104,15 +104,16 @@ class ServerRepository currentUserId = updatedUser.id.toServerString() }.build() } + val currentUser = CurrentUser(updatedServer, updatedUser) withContext(Dispatchers.Main) { - _current.value = CurrentUser(updatedServer, updatedUser) + _current.value = currentUser _currentUserDto.value = userDto } getServerSharedPreferences(context).edit(true) { putString(SERVER_URL_KEY, updatedServer.url) putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) } - return@withContext _current.value + return@withContext currentUser } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt index 698a5f34..3e3cbe18 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt @@ -12,7 +12,7 @@ import java.util.UUID * This is not the same thing as a Jellyfin server playlist */ class Playlist( - items: List, + items: List, startIndex: Int = 0, ) { val items = items.subList(startIndex, items.size) @@ -23,15 +23,15 @@ class Playlist( 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 = items.subList(index + 1, items.size) + fun upcomingItems(): List = items.subList(index + 1, items.size) - fun advanceTo(id: UUID): BaseItem? { + fun advanceTo(id: UUID): PlaylistItem? { while (hasNext()) { val potential = getAndAdvance() if (potential.id == id) { @@ -52,3 +52,22 @@ data class PlaylistInfo( val count: Int, 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 + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 5bf090f5..4e0ed2dd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -117,30 +117,6 @@ sealed interface AppPreference { }, ) -// val GridJumpButtons = -// AppSwitchPreference( -// 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( -// 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 = AppSliderPreference( title = R.string.hide_controller_timeout, @@ -513,6 +489,18 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) + val CinemaMode = + AppSwitchPreference( + 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 = AppSwitchPreference( title = R.string.remember_selected_tab, @@ -664,6 +652,13 @@ sealed interface AppPreference { valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 }, ) + val SkipSegments = + AppDestinationPreference( + title = R.string.skip_behavior, + summary = R.string.skip_behavior_summary, + destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS), + ) + val GlobalContentScale = AppChoicePreference( 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 = buildList { add( @@ -1203,7 +1213,9 @@ val advancedPreferences = preferences = listOf( AppPreference.OneClickPause, + AppPreference.CinemaMode, AppPreference.GlobalContentScale, + AppPreference.SkipSegments, AppPreference.MaxBitrate, AppPreference.RefreshRateSwitching, 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( PreferenceGroup( title = R.string.player_backend, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 9b46de83..278c148c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -54,6 +54,7 @@ class AppPreferencesSerializer refreshRateSwitching = AppPreference.RefreshRateSwitching.defaultValue resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue + cinemaMode = AppPreference.CinemaMode.defaultValue overrides = PlaybackOverrides diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 8899fe8e..778f300c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.preferences.updateMpvOptions import com.github.damontecres.wholphin.preferences.updateMusicPreferences import com.github.damontecres.wholphin.preferences.updatePhotoPreferences 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.updateSubtitlePreferences 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 } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt index 65b74083..e7770ff5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt @@ -1,10 +1,15 @@ 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.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.extensions.userLibraryApi import org.jellyfin.sdk.model.api.ExtraType +import org.jellyfin.sdk.model.api.ImageType import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -19,6 +24,8 @@ class ExtrasService @Inject constructor( private val api: ApiClient, + @param:ApplicationContext private val context: Context, + private val imageUrlService: ImageUrlService, ) { /** * Get the [ExtrasItem]s for the given item @@ -39,9 +46,45 @@ class ExtrasService extrasMap .mapNotNull { (type, items) -> 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) { - 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 { null } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt index beab6e64..5a6d8ff0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt @@ -6,12 +6,15 @@ import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem 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.showToast import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collectLatest import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.model.api.BaseItemKind @@ -68,6 +71,16 @@ class MediaManagementService } } + suspend fun collectCanDelete( + itemFlow: Flow, + 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. * diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt index 66fb851c..91c1e7cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt @@ -19,14 +19,6 @@ class PlaybackLifecycleObserver private var wasPlaying: Boolean? = null 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 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index da00fd3d..7f4886b4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -106,7 +106,13 @@ class PlayerFactory .setExtensionRendererMode(rendererMode) val mediaSourceFactory = 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) renderersFactory = AssRenderersFactory(assHandler, renderersFactory) DefaultMediaSourceFactory( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index fbbc9b70..7e115212 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -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.Playlist 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.components.baseItemKinds import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.indexOfFirstOrNull 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.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler @@ -22,6 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import org.jellyfin.sdk.api.client.ApiClient 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.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind @@ -247,14 +250,14 @@ class PlaylistCreator -> { val list = buildList { - add(BaseItem(item, false)) + add(PlaylistItem.Media(BaseItem(item, false))) if (item.partCount.gt(1)) { api.videosApi .getAdditionalPart(item.id) .content.items .map { - BaseItem(it, false) + PlaylistItem.Media(BaseItem(it, false)) }.let(::addAll) } } @@ -274,14 +277,14 @@ class PlaylistCreator } } - private suspend fun List.convertAndAddParts(useSeriesForPrimary: Boolean = false): List = + private suspend fun List.convertAndAddParts(useSeriesForPrimary: Boolean = false): List = buildList { this@convertAndAddParts.forEach { ep -> - add(BaseItem(ep, useSeriesForPrimary)) + add(PlaylistItem.Media(BaseItem(ep, useSeriesForPrimary))) if (ep.partCount.gt(1)) { val parts = api.videosApi.getAdditionalPart(ep.id).content.items.map { part -> - BaseItem(part, useSeriesForPrimary) + PlaylistItem.Media(BaseItem(part, useSeriesForPrimary)) } addAll(parts) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt index 6252a2a7..6069c00a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -4,7 +4,7 @@ import android.content.Context import coil3.imageLoader import coil3.request.ImageRequest 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.launchDefault import com.github.damontecres.wholphin.util.ApiRequestPager @@ -154,30 +154,20 @@ class ScreensaverService /** * Create a flow of items to show on the screensaver */ - fun createItemFlow(scope: CoroutineScope): Flow = + fun createItemFlow(scope: CoroutineScope): 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 = - 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) var index = 0 if (pager.isEmpty()) { - emit(null) + emit(ScreensaverItem.Empty) } else { val duration = userPreferencesService @@ -213,7 +203,14 @@ class ScreensaverService .build(), ).job .await() - emit(CurrentItem(item, backdropUrl, logoUrl, title ?: "")) + emit( + ScreensaverItem.CurrentItem( + item, + backdropUrl, + logoUrl, + title ?: "", + ), + ) delay(duration) } } @@ -228,6 +225,26 @@ class ScreensaverService } } }.flowOn(Dispatchers.Default).cancellable() + + private suspend fun createPager(): ApiRequestPager { + 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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt index f2a0bd23..b56d8a7d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -340,7 +340,7 @@ class UpdateChecker context.contentResolver.delete( MediaStore.Downloads.EXTERNAL_CONTENT_URI, "${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") } else { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt index 899498fd..e2c4f976 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt @@ -16,10 +16,32 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.Options import coil3.request.crossfade import coil3.util.DebugLogger +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences import okhttp3.OkHttpClient import timber.log.Timber 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 */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt index ab6263ae..78080c3a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp @@ -70,6 +71,8 @@ fun EpisodeCard( val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN val width = imageHeight * aspectRatio val height = imageWidth * (1f / aspectRatio) + val density = LocalDensity.current + val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } } Column( verticalArrangement = Arrangement.spacedBy(spaceBetween), modifier = modifier.size(width, height), @@ -102,6 +105,7 @@ fun EpisodeCard( watchedPercent = dto?.userData?.playedPercentage, numberOfVersions = dto?.mediaSourceCount ?: 0, useFallbackText = false, + fillWidth = imageWidthPx, modifier = Modifier .fillMaxSize(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt index adfff582..1d9927fe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt @@ -1,19 +1,13 @@ package com.github.damontecres.wholphin.ui.cards import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember 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.unit.Dp import com.github.damontecres.wholphin.R 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.Cards -import com.github.damontecres.wholphin.ui.LocalImageUrlService -import org.jellyfin.sdk.model.api.ImageType @Composable fun ExtrasRow( @@ -22,52 +16,27 @@ fun ExtrasRow( onLongClickItem: (Int, ExtrasItem) -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - val resources = LocalResources.current ItemRow( title = stringResource(R.string.extras), items = extras, onClickItem = onClickItem, onLongClickItem = onLongClickItem, 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( - 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 - }, + title = item?.title, name = null, - 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 - }, + subtitle = item?.subtitle, onClick = onClick, onLongClick = onLongClick, modifier = mod, showImageOverlay = true, - imageHeight = Cards.height2x3 * .75f, + imageHeight = Cards.heightEpisode, imageWidth = Dp.Unspecified, - imageUrl = imageUrl, + imageUrl = item?.imageUrl, isFavorite = false, - isPlayed = false, + isPlayed = item?.isPlayed == true, unplayedItemCount = -1, - playedPercentage = -1.0, + playedPercentage = item?.playedPercentage ?: 0.0, numberOfVersions = -1, aspectRatio = AspectRatios.FOUR_THREE, // TODO ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt index c191a70d..5cf16a61 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt @@ -47,6 +47,8 @@ fun GridCard( imageContentScale: ContentScale = ContentScale.Fit, imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, showTitle: Boolean = true, + fillWidth: Int? = null, + fillHeight: Int? = null, ) { val dto = item?.data val focused by interactionSource.collectIsFocusedAsState() @@ -95,6 +97,8 @@ fun GridCard( numberOfVersions = dto?.mediaSourceCount ?: 0, useFallbackText = false, contentScale = imageContentScale, + fillWidth = fillWidth, + fillHeight = fillHeight, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt index 8964477a..624f01c1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt @@ -24,11 +24,9 @@ import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onLayoutRectChanged import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme @@ -63,20 +61,19 @@ fun ItemCardImage( imageType: ImageType = ImageType.PRIMARY, useFallbackText: Boolean = true, contentScale: ContentScale = ContentScale.Fit, + fillWidth: Int? = null, + fillHeight: Int? = null, ) { val imageUrlService = LocalImageUrlService.current - var size by remember { mutableStateOf(IntSize.Zero) } val imageUrl = - remember(size, item) { - if (size != IntSize.Zero && item != null) { + remember(item) { + item?.let { imageUrlService.getItemImageUrl( item, imageType, - fillWidth = size.width, - fillHeight = size.height, + fillWidth = fillWidth, + fillHeight = fillHeight, ) - } else { - null } } ItemCardImage( @@ -88,13 +85,7 @@ fun ItemCardImage( unwatchedCount = unwatchedCount, watchedPercent = watchedPercent, numberOfVersions = numberOfVersions, - modifier = - modifier.onLayoutRectChanged( - throttleMillis = 100, - debounceMillis = 25, - ) { - size = IntSize(width = it.width, height = it.height) - }, + modifier = modifier, useFallbackText = useFallbackText, contentScale = contentScale, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt index fe4ae115..8aa43946 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt @@ -1,11 +1,15 @@ package com.github.damontecres.wholphin.ui.components +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition 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.background 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.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.graphicsLayer import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore @@ -45,6 +51,7 @@ import coil3.compose.AsyncImage import coil3.compose.useExistingImageAsPlaceholder import coil3.request.ImageRequest import coil3.request.transitionFactory +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreferences 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_END_FRACTION import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -67,12 +75,20 @@ class ScreensaverViewModel val currentItem = screensaverService.createItemFlow(viewModelScope) } -data class CurrentItem( - val item: BaseItem, - val backdropUrl: String, - val logoUrl: String?, - val title: String, -) +sealed interface ScreensaverItem { + data class Error( + val exception: Exception, + ) : ScreensaverItem + + data object Empty : ScreensaverItem + + data class CurrentItem( + val item: BaseItem, + val backdropUrl: String, + val logoUrl: String?, + val title: String, + ) : ScreensaverItem +} @Composable fun AppScreensaver( @@ -93,7 +109,7 @@ fun AppScreensaver( @OptIn(ExperimentalCoilApi::class) @Composable fun AppScreensaverContent( - currentItem: CurrentItem?, + currentItem: ScreensaverItem?, showClock: Boolean, duration: Duration, animate: Boolean, @@ -116,61 +132,83 @@ fun AppScreensaverContent( repeatMode = RepeatMode.Reverse, ), ) - 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), + when (currentItem) { + ScreensaverItem.Empty -> { + ScreensaverPlaceholder( + text = stringResource(R.string.no_results), + duration = duration, + modifier = Modifier.fillMaxSize(), ) } + + 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 = @@ -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), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index f574e883..8ed6acbe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -66,6 +66,7 @@ import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService 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.UserPreferencesService 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.cards.GridCard 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.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.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog 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.equalsNotNull import com.github.damontecres.wholphin.ui.launchDefault @@ -133,7 +135,7 @@ class CollectionFolderViewModel private val savedStateHandle: SavedStateHandle, api: ApiClient, @param:ApplicationContext private val context: Context, - private val serverRepository: ServerRepository, + val serverRepository: ServerRepository, private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val favoriteWatchManager: FavoriteWatchManager, private val backdropService: BackdropService, @@ -142,6 +144,7 @@ class CollectionFolderViewModel private val userPreferencesService: UserPreferencesService, private val mediaManagementService: MediaManagementService, private val musicService: MusicService, + val streamChoiceService: StreamChoiceService, val mediaReportService: MediaReportService, @Assisted itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @@ -570,7 +573,44 @@ fun CollectionFolderGrid( itemId.toServerString(), initialFilter, 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, + 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> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, +) = CollectionFolderGrid( + preferences, + itemId.toServerString(), + initialFilter, + recursive, + actions, sortOptions, playEnabled, viewModelKey = viewModelKey, @@ -590,7 +630,7 @@ fun CollectionFolderGrid( itemId: String, initialFilter: CollectionFolderFilter, recursive: Boolean, - onClickItem: (Int, BaseItem) -> Unit, + actions: GridClickActions, sortOptions: List, playEnabled: Boolean, defaultViewOptions: ViewOptions, @@ -625,11 +665,106 @@ fun CollectionFolderGrid( val item by viewModel.item.observeAsState() val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions) - var moreDialog by remember { mutableStateOf>(Optional.absent()) } + var showContextMenu by remember { mutableStateOf(null) } + var overviewDialog by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - var showDeleteDialog by remember { mutableStateOf(null) } 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) { DataLoadingState.Loading, DataLoadingState.Pending, @@ -662,10 +797,8 @@ fun CollectionFolderGrid( sortAndDirection = sortAndDirection!!, modifier = Modifier.fillMaxSize(), focusRequesterOnEmpty = focusRequesterOnEmpty, - onClickItem = onClickItem, - onLongClickItem = { position, item -> - moreDialog.makePresent(PositionItem(position, item)) - }, + onClickItem = gridActions.onClickItem, + onLongClickItem = gridActions.onLongClickItem!!, onSortChange = { viewModel.onSortChange(it, recursive, filter) }, @@ -689,47 +822,8 @@ fun CollectionFolderGrid( onSaveViewOptions = { viewModel.saveViewOptions(it) }, onChangeBackdrop = viewModel::updateBackdrop, playEnabled = playEnabled, - onClickPlay = { 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) - }, - 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) - } - }, + onClickPlay = gridActions.onClickPlayRemoteButton!!, + onClickPlayAll = gridActions.onClickPlayAll!!, ) AnimatedVisibility( @@ -751,47 +845,22 @@ fun CollectionFolderGrid( } } } - moreDialog.compose { (position, item) -> - DialogPopup( - showDialog = true, - title = item.title ?: "", - dialogItems = - buildMoreDialogItemsForHome( - context = context, - item = item, - seriesId = null, - playbackPosition = item.playbackPosition, - watched = item.played, - favorite = item.favorite, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - actions = - MoreDialogActions( - navigateTo = { viewModel.navigateTo(it) }, - 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, + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = + viewModel.serverRepository.currentUserDto.value + ?.policy + ?.isAdministrator == true, + onDismissRequest = { overviewDialog = null }, + ) + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, ) } showPlaylistDialog.compose { itemId -> @@ -811,16 +880,6 @@ fun CollectionFolderGrid( 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) @@ -1002,7 +1061,7 @@ fun CollectionFolderGridContent( position = newPosition positionCallback?.invoke(columns, newPosition) }, - cardContent = { item, onClick, onLongClick, mod -> + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, onClick = onClick, @@ -1011,6 +1070,7 @@ fun CollectionFolderGridContent( imageAspectRatio = viewOptions.aspectRatio.ratio, imageType = viewOptions.imageType, showTitle = viewOptions.showTitles, + fillWidth = widthPx, modifier = mod, ) }, @@ -1051,17 +1111,13 @@ data class PositionItem( data class CollectionFolderGridParameters( val columns: Int = 6, val spacing: Dp = 16.dp, - val cardContent: @Composable ( - item: BaseItem?, - onClick: () -> Unit, - onLongClick: () -> Unit, - mod: Modifier, - ) -> Unit = { item, onClick, onLongClick, mod -> + val cardContent: @Composable (GridItemDetails) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.FillBounds, + fillWidth = widthPx, modifier = mod, ) }, @@ -1071,13 +1127,14 @@ data class CollectionFolderGridParameters( CollectionFolderGridParameters( columns = 6, spacing = 16.dp, - cardContent = { item, onClick, onLongClick, mod -> + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.FillBounds, imageAspectRatio = AspectRatios.TALL, + fillWidth = widthPx, modifier = mod, ) }, @@ -1086,13 +1143,14 @@ data class CollectionFolderGridParameters( CollectionFolderGridParameters( columns = 4, spacing = 24.dp, - cardContent = { item, onClick, onLongClick, mod -> + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.Crop, imageAspectRatio = AspectRatios.WIDE, + fillWidth = widthPx, modifier = mod, ) }, @@ -1101,13 +1159,14 @@ data class CollectionFolderGridParameters( CollectionFolderGridParameters( columns = 6, spacing = 16.dp, - cardContent = { item, onClick, onLongClick, mod -> + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.FillBounds, imageAspectRatio = AspectRatios.SQUARE, + fillWidth = widthPx, modifier = mod, ) }, @@ -1165,3 +1224,10 @@ fun GridTitle( overflow = TextOverflow.Ellipsis, 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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt new file mode 100644 index 00000000..e2111637 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -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(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 = + 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 = + 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 = + 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) + }, + ) + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 538f623b..58bcde20 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -104,11 +104,13 @@ data class DialogItem( val trailingContent: @Composable (() -> Unit)? = null, val enabled: Boolean = true, val selected: Boolean = false, + val dismissOnClick: Boolean = false, ) : DialogItemEntry { constructor( @StringRes text: Int, @StringRes iconStringRes: Int, iconColor: Color = Color.Unspecified, + dismissOnClick: Boolean = false, onClick: () -> Unit, ) : this( headlineContent = { @@ -124,11 +126,13 @@ data class DialogItem( ) }, onClick = onClick, + dismissOnClick = dismissOnClick, ) constructor( text: String, @StringRes iconStringRes: Int, + dismissOnClick: Boolean = false, onClick: () -> Unit, ) : this( headlineContent = { @@ -145,12 +149,14 @@ data class DialogItem( ) }, onClick = onClick, + dismissOnClick = dismissOnClick, ) constructor( text: String, icon: ImageVector, iconColor: Color? = null, + dismissOnClick: Boolean = false, onClick: () -> Unit, ) : this( headlineContent = { @@ -167,10 +173,12 @@ data class DialogItem( ) }, onClick = onClick, + dismissOnClick = dismissOnClick, ) constructor( text: String, + dismissOnClick: Boolean = false, onClick: () -> Unit, ) : this( headlineContent = { @@ -180,6 +188,7 @@ data class DialogItem( ) }, onClick = onClick, + dismissOnClick = dismissOnClick, ) companion object { @@ -201,7 +210,7 @@ fun DialogPopup( dismissOnClick: Boolean = true, waitToLoad: Boolean = true, properties: DialogProperties = DialogProperties(), - elevation: Dp = 8.dp, + elevation: Dp = 1.dp, ) { var waiting by remember { mutableStateOf(waitToLoad) } if (showDialog) { @@ -296,7 +305,7 @@ fun DialogPopupContent( selected = item.selected, enabled = !waiting && item.enabled, onClick = { - if (dismissOnClick) { + if (dismissOnClick || item.dismissOnClick) { onDismissRequest.invoke() } item.onClick.invoke() @@ -349,7 +358,7 @@ fun DialogPopup( onDismissRequest: () -> Unit, dismissOnClick: Boolean = true, properties: DialogProperties = DialogProperties(), - elevation: Dp = 8.dp, + elevation: Dp = 1.dp, ) = DialogPopup( showDialog = true, waitToLoad = params.fromLongClick, @@ -427,7 +436,7 @@ fun ScrollableDialog( fun BasicDialog( onDismissRequest: () -> Unit, properties: DialogProperties = DialogProperties(), - elevation: Dp = 8.dp, + elevation: Dp = 1.dp, content: @Composable () -> Unit, ) { Dialog( @@ -458,7 +467,7 @@ fun ConfirmDialog( onCancel: () -> Unit, onConfirm: () -> Unit, properties: DialogProperties = DialogProperties(), - elevation: Dp = 8.dp, + elevation: Dp = 1.dp, bodyColor: Color = MaterialTheme.colorScheme.onSurface, ) = BasicDialog( onDismissRequest = onCancel, @@ -527,10 +536,12 @@ fun ConfirmDeleteDialog( itemTitle: String, onCancel: () -> Unit, onConfirm: () -> Unit, + elevation: Dp = 3.dp, ) { BasicDialog( onDismissRequest = onCancel, properties = DialogProperties(usePlatformDefaultWidth = false), + elevation = elevation, ) { LazyColumn( horizontalAlignment = Alignment.CenterHorizontally, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index 21ac519d..4723627f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -309,7 +309,7 @@ fun GenreCardGrid( }, columns = columns, spacing = spacing, - cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> GenreCard( genre = item, onClick = onClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt new file mode 100644 index 00000000..d74707e5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt @@ -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(), +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt index 13ce42a7..dff6d505 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt @@ -133,11 +133,12 @@ fun ItemGrid( showJumpButtons = false, showLetterButtons = false, spacing = 24.dp, - cardContent = @Composable { item, onClick, onLongClick, mod -> + cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, + fillWidth = widthPx, modifier = mod, imageAspectRatio = AspectRatios.WIDE, // TODO ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index 75a3147c..ca4c11b8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -69,6 +69,7 @@ import kotlin.time.Duration.Companion.seconds */ @Composable fun ExpandablePlayButtons( + title: String, resumePosition: Duration, watched: Boolean, favorite: Boolean, @@ -79,7 +80,7 @@ fun ExpandablePlayButtons( favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, trailerOnClick: (Trailer) -> Unit, - deleteOnClick: () -> Unit, + onConfirmDelete: () -> Unit, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { @@ -163,7 +164,8 @@ fun ExpandablePlayButtons( if (canDelete) { item("delete") { DeleteButton( - onClick = deleteOnClick, + title = title, + onConfirmDelete = onConfirmDelete, modifier = Modifier .onFocusChanged(buttonOnFocusChanged), @@ -381,10 +383,12 @@ fun TrailerButton( @Composable fun DeleteButton( - onClick: () -> Unit, + onConfirmDelete: () -> Unit, + title: String, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { + var showDeleteDialog by remember { mutableStateOf(false) } val focused by interactionSource.collectIsFocusedAsState() val iconTint by animateColorAsState( @@ -415,10 +419,20 @@ fun DeleteButton( .size(24.dp), ) }, - onClick = { onClick.invoke() }, + onClick = { showDeleteDialog = true }, interactionSource = interactionSource, modifier = modifier, ) + if (showDeleteDialog) { + ConfirmDeleteDialog( + itemTitle = title, + onCancel = { showDeleteDialog = false }, + onConfirm = { + onConfirmDelete.invoke() + showDeleteDialog = false + }, + ) + } } @PreviewTvSpec @@ -426,6 +440,7 @@ fun DeleteButton( private fun ExpandablePlayButtonsPreview() { WholphinTheme(true) { ExpandablePlayButtons( + title = "Movie", resumePosition = 10.seconds, watched = false, favorite = false, @@ -437,7 +452,7 @@ private fun ExpandablePlayButtonsPreview() { trailers = listOf(), trailerOnClick = {}, canDelete = true, - deleteOnClick = {}, + onConfirmDelete = {}, modifier = Modifier, ) } @@ -482,7 +497,8 @@ private fun ViewOptionsPreview() { modifier = Modifier, ) DeleteButton( - onClick = {}, + onConfirmDelete = {}, + title = "Movie", ) SortByButton( sortOptions = listOf(), @@ -491,7 +507,8 @@ private fun ViewOptionsPreview() { ) } DeleteButton( - onClick = {}, + onConfirmDelete = {}, + title = "Movie", interactionSource = source, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 1e2d3e61..ad7010cf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -18,6 +17,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.HomeRowViewOptions 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.ui.OneTimeLaunchedEffect 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.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog 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.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -60,6 +60,7 @@ import java.util.UUID abstract class RecommendedViewModel( @param:ApplicationContext val context: Context, val api: ApiClient, + val serverRepository: ServerRepository, val navigationManager: NavigationManager, val favoriteWatchManager: FavoriteWatchManager, val mediaReportService: MediaReportService, @@ -167,10 +168,9 @@ fun RecommendedContent( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), onFocusPosition: ((RowColumn) -> Unit)? = null, ) { - val context = LocalContext.current - var moreDialog by remember { mutableStateOf>(Optional.absent()) } + var showContextMenu by remember { mutableStateOf(null) } + var overviewDialog by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - var showDeleteDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) OneTimeLaunchedEffect { @@ -192,6 +192,35 @@ fun RecommendedContent( LoadingState.Success -> { 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( homeRows = rows, position = position, @@ -199,7 +228,18 @@ fun RecommendedContent( viewModel.navigationManager.navigateTo(item.destination()) }, 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 -> viewModel.navigationManager.navigateTo(Destination.Playback(item)) @@ -226,42 +266,22 @@ fun RecommendedContent( ) } } - moreDialog.compose { (position, item) -> - DialogPopup( - showDialog = true, - title = item.title ?: "", - dialogItems = - buildMoreDialogItemsForHome( - context = context, - item = item, - seriesId = null, - playbackPosition = item.playbackPosition, - watched = item.played, - favorite = item.favorite, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - actions = - MoreDialogActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, - 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, + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = + viewModel.serverRepository.currentUserDto.value + ?.policy + ?.isAdministrator == true, + onDismissRequest = { overviewDialog = null }, + ) + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, ) } showPlaylistDialog.compose { itemId -> @@ -281,16 +301,6 @@ fun RecommendedContent( 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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index 3228046b..71b762f0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -58,7 +58,7 @@ class RecommendedMovieViewModel @ApplicationContext context: Context, api: ApiClient, musicService: MusicService, - private val serverRepository: ServerRepository, + serverRepository: ServerRepository, private val preferencesDataStore: DataStore, private val suggestionService: SuggestionService, @Assisted val parentId: UUID, @@ -70,6 +70,7 @@ class RecommendedMovieViewModel ) : RecommendedViewModel( context, api, + serverRepository, navigationManager, favoriteWatchManager, mediaReportService, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt index e659347b..30576e7b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt @@ -57,7 +57,7 @@ class RecommendedMusicViewModel @ApplicationContext context: Context, api: ApiClient, musicService: MusicService, - private val serverRepository: ServerRepository, + serverRepository: ServerRepository, private val preferencesDataStore: DataStore, private val suggestionService: SuggestionService, @Assisted val parentId: UUID, @@ -69,6 +69,7 @@ class RecommendedMusicViewModel ) : RecommendedViewModel( context, api, + serverRepository, navigationManager, favoriteWatchManager, mediaReportService, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index a48af22d..ce50249e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -61,7 +61,7 @@ class RecommendedTvShowViewModel @ApplicationContext context: Context, api: ApiClient, musicService: MusicService, - private val serverRepository: ServerRepository, + serverRepository: ServerRepository, private val preferencesDataStore: DataStore, private val lastestNextUpService: LatestNextUpService, private val suggestionService: SuggestionService, @@ -74,6 +74,7 @@ class RecommendedTvShowViewModel ) : RecommendedViewModel( context, api, + serverRepository, navigationManager, favoriteWatchManager, mediaReportService, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt index b52c7a8e..92605dfc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -205,7 +205,7 @@ fun StudioCardGrid( }, columns = columns, spacing = spacing, - cardContent = { item: Studio?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> StudioCard( studio = item, onClick = onClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 41f5e534..2c13c0ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -14,6 +14,7 @@ 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.padding @@ -34,6 +35,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.onKeyEvent 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.text.style.TextAlign import androidx.compose.ui.unit.Dp @@ -62,10 +65,8 @@ import androidx.tv.material3.LocalContentColor 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.AppColors 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.playback.isBackwardButton import com.github.damontecres.wholphin.ui.playback.isForwardButton @@ -76,6 +77,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber +import kotlin.math.ceil private const val DEBUG = false @@ -85,6 +87,15 @@ interface CardGridItem { val sortName: String } +data class GridItemDetails( + 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 */ @@ -102,31 +113,21 @@ fun CardGrid( modifier: Modifier = Modifier, initialPosition: Int = 0, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - cardContent: @Composable ( - 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, - ) - }, + cardContent: @Composable (GridItemDetails) -> Unit, columns: Int = 6, spacing: Dp = 16.dp, 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) } + val currentFocusedIndex by rememberUpdatedState(focusedIndex) val gridState = rememberLazyGridState( - cacheWindow = fractionCacheWindow, + cacheWindow = LazyLayoutCacheWindow(aheadFraction = 2f, behindFraction = 0.5f), initialFirstVisibleItemIndex = focusedIndex, ) val scope = rememberCoroutineScope() @@ -135,13 +136,16 @@ fun CardGrid( var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) } var alphabetFocus by remember { mutableStateOf(false) } - val focusOn = { index: Int -> - if (DEBUG) Timber.v("focusOn: focusedIndex=$focusedIndex, index=$index") - if (index != focusedIndex) { - previouslyFocusedIndex = focusedIndex + val focusOn = + remember { + { index: Int -> + if (DEBUG) Timber.v("focusOn: focusedIndex=$currentFocusedIndex, index=$index") + if (index != currentFocusedIndex) { + previouslyFocusedIndex = focusedIndex + } + focusedIndex = index + } } - focusedIndex = index - } // Wait for a recomposition to focus val alphabetFocusRequester = remember { FocusRequester() } @@ -180,27 +184,33 @@ fun CardGrid( } } - val jump = { jump: Int -> - scope.launch(ExceptionHandler()) { - val newPosition = - (gridState.firstVisibleItemIndex + jump).coerceIn(0.. + scope.launch(ExceptionHandler()) { + val newPosition = + (gridState.firstVisibleItemIndex + jump).coerceIn(0.. Unit = remember { @@ -298,6 +308,9 @@ fun CardGrid( modifier = Modifier.align(Alignment.CenterVertically), ) } + val density = LocalDensity.current + var cardWidthPx by rememberSaveable { mutableIntStateOf(0) } + Box( modifier = Modifier.weight(1f), ) { @@ -314,57 +327,69 @@ fun CardGrid( .focusGroup() .focusRestorer(firstFocus) .focusProperties { - onExit = { - // Leaving the grid, so "forget" the position -// focusedIndex = -1 - } onEnter = { if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= 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 -> - 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] - cardContent( - item, - { - if (item != null) { - focusedIndex = index - onClickItem.invoke(index, item) - } - }, - { if (item != null) onLongClickItem.invoke(index, item) }, - mod - .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) - .onFocusChanged { focusState -> - if (DEBUG) { - Timber.v( - "$index isFocused=${focusState.isFocused}", - ) + val details = + remember(index, item) { + val mod = + if ((index == currentFocusedIndex) or (currentFocusedIndex < 0 && index == 0)) { + if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") + Modifier + .focusRequester(firstFocus) + .focusRequester(gridFocusRequester) + .focusRequester(alphabetFocusRequester) + } else { + Modifier } - if (focusState.isFocused) { - // Focused, so set that up - focusOn(index) - positionCallback?.invoke(columns, index) - } else if (focusedIndex == index) { -// savedFocusedIndex = index -// // Was focused on this, so mark unfocused -// focusedIndex = -1 - } - }, - ) + GridItemDetails( + item = item, + index = index, + onClick = { + if (item != null) { + focusedIndex = index + onClickItem.invoke(index, item) + } + }, + 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()) { @@ -429,11 +454,21 @@ fun CardGrid( // Add end padding to push away from edge 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 fun JumpButtons( jump1: Int, @@ -500,7 +535,7 @@ fun AlphabetButtons( var alphabetPickerFocused by remember { mutableStateOf(false) } LazyColumn( - contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = 2.dp), + contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = letterHorizontalPadding), verticalArrangement = Arrangement.spacedBy(1.1.dp), state = listState, modifier = @@ -534,14 +569,14 @@ fun AlphabetButtons( Box( modifier = Modifier - .size(14.dp) + .size(letterButtonSize) .clip(CircleShape) .alpha(itemAlpha), ) { Button( modifier = Modifier - .size(14.dp) + .size(letterButtonSize) .focusRequester(focusRequesters[index]), contentPadding = PaddingValues(0.dp), // No padding to maximize text space interactionSource = interactionSource, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt index 30534ffc..1911fc7a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -23,14 +23,18 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter 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.NavigationManager import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ErrorMessage 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.TabRow 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.logTab 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 org.jellyfin.sdk.api.client.ApiClient 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 - @Inject + @AssistedInject constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, 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) { if (item.type == BaseItemKind.AUDIO) { 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 @@ -65,11 +126,13 @@ fun CollectionFolderMusic( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - viewModel: CollectionFolderMusicViewModel = hiltViewModel(), - preferencesViewModel: PreferencesViewModel = hiltViewModel(), + viewModel: CollectionFolderMusicViewModel = + hiltViewModel( + creationCallback = { it.create(destination.itemId) }, + ), ) { val rememberedTabIndex = - remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } + remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } val tabs = listOf( @@ -88,12 +151,22 @@ fun CollectionFolderMusic( LaunchedEffect(selectedTabIndex) { logTab("music", selectedTabIndex) - preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - preferencesViewModel.backdropService.clearBackdrop() + viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) + viewModel.backdropService.clearBackdrop() } var showHeader by rememberSaveable { mutableStateOf(true) } + val actions = + remember { + GridClickActions( + onClickItem = viewModel::onClick, + onLongClickItem = null, + onClickPlayAll = viewModel::onClickPlayAll, + onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton, + ) + } + Column( modifier = modifier, ) { @@ -133,9 +206,7 @@ fun CollectionFolderMusic( 1 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = { _, item -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - }, + actions = actions, itemId = destination.itemId, viewModelKey = "${destination.itemId}_albums", initialFilter = @@ -165,9 +236,7 @@ fun CollectionFolderMusic( 2 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = { _, item -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - }, + actions = actions, itemId = destination.itemId, viewModelKey = "${destination.itemId}_artists", initialFilter = @@ -209,9 +278,7 @@ fun CollectionFolderMusic( 4 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = { _, item -> - viewModel.play(item) - }, + actions = actions, itemId = destination.itemId, viewModelKey = "${destination.itemId}_songs", initialFilter = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt index 562af214..62186f55 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid 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.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.VideoSortOptions @@ -51,22 +52,32 @@ fun CollectionFolderPhotoAlbum( var showHeader by remember { mutableStateOf(true) } CollectionFolderGrid( preferences = preferences, - onClickItem = { index, item -> - val destination = - if (item.type == BaseItemKind.PHOTO) { - Destination.Slideshow( - parentId = itemId, - index = index, - filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()), - sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT, - recursive = recursive, - startSlideshow = false, - ) - } else { - item.destination(index) - } - viewModel.navigateTo(destination) - }, + actions = + remember { + GridClickActions( + onClickItem = { index, item -> + val destination = + if (item.type == BaseItemKind.PHOTO) { + Destination.Slideshow( + parentId = itemId, + index = index, + filter = + CollectionFolderFilter( + filter = viewModel.filter.value ?: GetItemsFilter(), + ), + sortAndDirection = + viewModel.sortAndDirection.value + ?: SortAndDirection.DEFAULT, + recursive = recursive, + startSlideshow = false, + ) + } else { + item.destination(index) + } + viewModel.navigateTo(destination) + }, + ) + }, itemId = itemId.toServerString(), initialFilter = filter, showTitle = showHeader, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 049569d9..20172832 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -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.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.ui.graphics.Color import com.github.damontecres.wholphin.R 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.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.BaseItemKind -import org.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -36,234 +31,6 @@ data class MoreDialogActions( 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 = - 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( context: Context, item: BaseItem, @@ -424,29 +191,3 @@ fun buildMoreDialogItemsForHome( }, ) } - -fun buildMoreDialogItemsForPerson( - context: Context, - person: Person, - actions: MoreDialogActions, -): List = - 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) - }, - ) - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index 50b0053e..9b03aa0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -29,6 +29,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid 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.ViewOptionsPoster import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare @@ -109,13 +110,20 @@ fun FavoritesPage( focusRequesters = tabFocusRequesters, ) } + val actions = + remember { + GridClickActions( + onClickItem = { _, item -> onClickItem.invoke(item) }, + ) + } + // TODO playEnabled = true for movies & episodes when (selectedTabIndex) { // Movies 0 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = { _, item -> onClickItem.invoke(item) }, + actions = actions, itemId = "${NavDrawerItem.Favorites.id}_movies", initialFilter = CollectionFolderFilter( @@ -147,7 +155,7 @@ fun FavoritesPage( 1 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = { _, item -> onClickItem.invoke(item) }, + actions = actions, itemId = "${NavDrawerItem.Favorites.id}_series", initialFilter = CollectionFolderFilter( @@ -179,7 +187,7 @@ fun FavoritesPage( 2 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = { _, item -> onClickItem.invoke(item) }, + actions = actions, itemId = "${NavDrawerItem.Favorites.id}_episodes", initialFilter = CollectionFolderFilter( @@ -212,7 +220,7 @@ fun FavoritesPage( 3 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = { _, item -> onClickItem.invoke(item) }, + actions = actions, itemId = "${NavDrawerItem.Favorites.id}_videos", initialFilter = CollectionFolderFilter( @@ -244,7 +252,7 @@ fun FavoritesPage( 4 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = { _, item -> onClickItem.invoke(item) }, + actions = actions, itemId = "${NavDrawerItem.Favorites.id}_playlists", initialFilter = CollectionFolderFilter( @@ -276,7 +284,7 @@ fun FavoritesPage( 5 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = { _, item -> onClickItem.invoke(item) }, + actions = actions, itemId = "${NavDrawerItem.Favorites.id}_people", initialFilter = CollectionFolderFilter( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index ff5809fb..c69fc0e6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign 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.cards.ItemCardImage import com.github.damontecres.wholphin.ui.components.BasicDialog -import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup +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.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton import com.github.damontecres.wholphin.ui.components.FilterByButton 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.OverviewText 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.BoxSetSortOptions 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.MusicViewModel -import com.github.damontecres.wholphin.ui.detail.music.buildMoreDialogForMusic import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.formatDateTime @@ -371,7 +371,7 @@ fun PlaylistDetails( val state by viewModel.state.collectAsState() val musicState by viewModel.musicState.collectAsState() - var longClickDialog by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(null) } var showConfirmTypeDialog by remember { mutableStateOf?>(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) @@ -404,34 +404,40 @@ fun PlaylistDetails( } } } - var showDeleteDialog by remember { mutableStateOf(null) } - val musicMoreActions = - MusicMoreDialogActions( - onNavigate = { viewModel.navigationManager.navigateTo(it) }, + val musicContextActions = + MusicContextActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) }, 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) }, onClickAddPlaylist = { itemId -> addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO) showPlaylistDialog.makePresent(itemId) }, - onClickRemoveFromQueue = {}, - onClickDelete = { showDeleteDialog = it }, - ) - 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) }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = viewModel::deleteItem, ) + 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( loadingState = state.loading, @@ -448,43 +454,41 @@ fun PlaylistDetails( } }, onLongClickIndex = { index, item -> - longClickDialog = - DialogParams( - fromLongClick = true, - title = item.name ?: "", - items = - if (item.type == BaseItemKind.AUDIO) { - buildMoreDialogForMusic( - context = context, - actions = musicMoreActions, - item = item, - index = index, - canRemove = false, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - ) - } else { - buildMoreDialogItemsForHome( - context = context, - item = item, - seriesId = item.data.seriesId, - playbackPosition = item.playbackPosition, - watched = item.played, - favorite = item.favorite, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - actions = moreActions, - ) - }, - ) + showContextMenu = + if (item.type == BaseItemKind.AUDIO) { + ContextMenu.ForMusic( + fromLongClick = true, + item = item, + index = index, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + canRemoveFromQueue = false, + actions = musicContextActions, + ) + } else { + ContextMenu.ForBaseItem( + fromLongClick = true, + item = item, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) + } }, filterAndSort = state.filterAndSort, onFilterAndSortChange = viewModel::loadItems, getPossibleFilterValues = viewModel::getFilterOptionValues, modifier = modifier, ) - longClickDialog?.let { params -> - DialogPopup( - params = params, - onDismissRequest = { longClickDialog = null }, + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, ) } showConfirmTypeDialog?.let { (index, item, shuffle) -> @@ -510,16 +514,6 @@ fun PlaylistDetails( elevation = 3.dp, ) } - showDeleteDialog?.let { item -> - ConfirmDeleteDialog( - itemTitle = item.title ?: "", - onCancel = { showDeleteDialog = null }, - onConfirm = { - viewModel.deleteItem(item) - showDeleteDialog = null - }, - ) - } } @Composable @@ -788,6 +782,9 @@ fun PlaylistItem( isQueued: Boolean = false, ) { val focused by interactionSource.collectIsFocusedAsState() + val imageWidth = 160.dp + val density = LocalDensity.current + val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } } ListItem( selected = false, onClick = onClick, @@ -850,8 +847,9 @@ fun PlaylistItem( unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, watchedPercent = 0.0, numberOfVersions = item?.data?.mediaSourceCount ?: 0, - modifier = Modifier.width(160.dp), + modifier = Modifier.width(imageWidth), useFallbackText = false, + fillWidth = imageWidthPx, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt index b10e9a17..759da02e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt @@ -40,7 +40,7 @@ fun CollectionButtons( getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, onClickViewOptions: () -> Unit, favoriteOnClick: () -> Unit, - deleteOnClick: () -> Unit, + onConfirmDelete: () -> Unit, canDelete: Boolean, moreOnClick: () -> Unit, modifier: Modifier = Modifier, @@ -91,9 +91,9 @@ fun CollectionButtons( if (canDelete) { item("delete") { DeleteButton( - onClick = deleteOnClick, - modifier = - Modifier, + title = state.collection?.title ?: "", + onConfirmDelete = onConfirmDelete, + modifier = Modifier, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt index 7e0ca325..4da00ef7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt @@ -1,13 +1,11 @@ package com.github.damontecres.wholphin.ui.detail.collection -import android.content.Context import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.padding 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.LaunchedEffect 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.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource 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.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup +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.ErrorMessage 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.Optional 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.RowColumn 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.PlaylistLoadingState -import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -85,53 +77,58 @@ fun CollectionDetails( val state by viewModel.state.collectAsState() // Dialogs - var moreDialog by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) - var showDeleteDialog by remember { mutableStateOf?>(null) } var showViewOptionsDialog by remember { mutableStateOf(false) } var overviewDialog by remember { mutableStateOf(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 val onClickItem = remember { - { _: RowColumn, item: BaseItem -> viewModel.navigate(item.destination()) } + { _: RowColumn, item: BaseItem -> viewModel.navigateTo(item.destination()) } } val onLongClickItem = remember { { position: RowColumn, item: BaseItem -> - val dialogItems = - buildMoreDialogItemsForHome( - 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( + showContextMenu = + ContextMenu.ForBaseItem( fromLongClick = true, - title = item.title ?: "", - items = dialogItems, + item = item, + 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) } } val onClickPlay = { _: RowColumn, item: BaseItem -> - viewModel.navigate(Destination.Playback(item = item)) + viewModel.navigateTo(Destination.Playback(item = item)) } val onClickPlayAll = remember { @@ -158,7 +155,7 @@ fun CollectionDetails( sortAndDirection = state.sortAndDirection, filter = state.itemFilter, ) - viewModel.navigate(dest) + viewModel.navigateTo(dest) } } val onClickViewOptions = remember { { showViewOptionsDialog = true } } @@ -201,7 +198,7 @@ fun CollectionDetails( } } }, - deleteOnClick = + onConfirmDelete = remember { { state.collection?.let { @@ -210,43 +207,30 @@ fun CollectionDetails( } }, canDelete = - remember(state.collection) { + remember( + state.collection, + preferences.appPreferences.interfacePreferences.enableMediaManagement, + ) { state.collection?.let { viewModel.canDelete(it, preferences.appPreferences) } ?: false }, moreOnClick = { - val collection = state.collection!! - val items = - 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( + showContextMenu = + ContextMenu.ForBaseItem( fromLongClick = false, - title = collection.title ?: "", - items = items, + item = state.collection!!, + 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, ) } - moreDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { moreDialog = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = + viewModel.serverRepository.currentUserDto.value + ?.policy + ?.isAdministrator == true, + onDismissRequest = { overviewDialog = null }, + ) + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, ) } showPlaylistDialog.compose { itemId -> @@ -286,26 +278,6 @@ fun CollectionDetails( 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 @@ -324,7 +296,7 @@ fun CollectionDetailsContent( onClickViewOptions: () -> Unit, overviewOnClick: () -> Unit, favoriteOnClick: () -> Unit, - deleteOnClick: () -> Unit, + onConfirmDelete: () -> Unit, canDelete: Boolean, moreOnClick: () -> Unit, modifier: Modifier, @@ -363,15 +335,9 @@ fun CollectionDetailsContent( ) { // This box exists so that there is something focusable above the item content // allowing focus to move up to restore the collection's header - Box( - modifier = - Modifier - .fillMaxWidth() - .height(0.dp) - .onFocusChanged { - if (it.isFocused) itemsContentHasFocus = false - }.focusable(), - ) + HiddenFocusBox { + itemsContentHasFocus = false + } if (state.viewOptions.cardViewOptions.showDetails) { HomePageHeader( item = focusedItem, @@ -429,7 +395,7 @@ fun CollectionDetailsContent( getPossibleFilterValues = getPossibleFilterValues, onClickViewOptions = onClickViewOptions, favoriteOnClick = favoriteOnClick, - deleteOnClick = deleteOnClick, + onConfirmDelete = onConfirmDelete, canDelete = canDelete, moreOnClick = moreOnClick, modifier = @@ -491,60 +457,3 @@ fun CollectionDetailsContent( } } } - -fun buildMoreDialogItemsForCollection( - context: Context, - item: BaseItem, - favorite: Boolean, - canDelete: Boolean, - onClickPlayAll: (shuffle: Boolean) -> Unit, - actions: MoreDialogActions, -): List = - 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) - }, - ) - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt index 64a6eb10..757b4cf9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt @@ -60,7 +60,7 @@ fun CollectionMixedGrid( positionCallback = { _, newPosition -> onFocusPosition.invoke(RowColumn(0, newPosition)) }, - cardContent = { item, onClick, onLongClick, mod -> + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, onClick = onClick, @@ -69,6 +69,7 @@ fun CollectionMixedGrid( imageAspectRatio = cardViewOptions.aspectRatio.ratio, imageType = cardViewOptions.imageType, showTitle = cardViewOptions.showTitles, + fillWidth = widthPx, modifier = mod, ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt index b0a6c606..915471d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -382,7 +382,7 @@ class CollectionViewModel result.totalRecordCount } - fun navigate(destination: Destination) { + fun navigateTo(destination: Destination) { release() navigationManager.navigateTo(destination) } @@ -393,7 +393,9 @@ class CollectionViewModel position: RowColumn?, ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { favoriteWatchManager.setWatched(itemId, played) - if (position != null) { + if (itemId == state.value.collection?.id) { + refreshCollection() + } else if (position != null) { refreshItem(itemId, position, false) } } @@ -404,11 +406,24 @@ class CollectionViewModel position: RowColumn?, ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) - if (position != null) { + if (itemId == state.value.collection?.id) { + refreshCollection() + } else if (position != null) { 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( item: BaseItem, appPreferences: AppPreferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt index f7d50f31..5987b7eb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt @@ -142,7 +142,7 @@ fun DiscoverPersonPage( showJumpButtons = false, showLetterButtons = false, spacing = 16.dp, - cardContent = @Composable { item, onClick, onLongClick, mod -> + cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) -> DiscoverItemCard( item = item, onClick = onClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index 79637ee0..fa39f919 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -31,33 +31,27 @@ 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.ui.RequestOrRestoreFocus -import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup +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.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage 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.ItemDetailsDialog 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.PlaylistLoadingState -import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import kotlin.time.Duration @@ -82,10 +76,8 @@ fun EpisodeDetails( val chosenStreams by viewModel.chosenStreams.observeAsState(null) var overviewDialog by remember { mutableStateOf(null) } - var moreDialog by remember { mutableStateOf(null) } - var chooseVersion by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - var showDeleteDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val canDelete by viewModel.canDelete.collectAsState() @@ -96,22 +88,36 @@ fun EpisodeDetails( ?.configuration ?.subtitleLanguagePreference - val moreActions = - MoreDialogActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched(itemId, watched) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - onClickDelete = { showDeleteDialog = it }, - ) + val contextActions = + remember { + ContextMenuActions( + navigateTo = viewModel::navigateTo, + onClickWatch = viewModel::setWatched, + onClickFavorite = viewModel::setFavorite, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onDeleteItem = viewModel::deleteItem, + onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, + onChooseVersion = { item, source -> + 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) { is LoadingState.Error -> { @@ -152,75 +158,17 @@ fun EpisodeDetails( ItemDetailsDialogInfo(ep) }, moreOnClick = { - moreDialog = - DialogParams( + showContextMenu = + ContextMenu.ForBaseItem( fromLongClick = false, - title = ep.name + " (${ep.data.productionYear ?: ""})", - items = - buildMoreDialogItems( - context = context, - item = ep, - watched = ep.data.userData?.played ?: false, - favorite = ep.data.userData?.isFavorite ?: false, - seriesId = ep.data.seriesId, - 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, - ), + item = ep, + chosenStreams = chosenStreams, + showGoTo = false, + showStreamChoices = true, + canDelete = canDelete, + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, ) }, watchOnClick = { @@ -230,7 +178,7 @@ fun EpisodeDetails( viewModel.setFavorite(ep.id, !ep.favorite) }, canDelete = canDelete, - deleteOnClick = { showDeleteDialog = ep }, + onConfirmDelete = { viewModel.deleteItem(ep) }, modifier = modifier, ) } @@ -246,24 +194,12 @@ fun EpisodeDetails( 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, + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = viewModel.streamChoiceService::chooseSource, + contextMenu = contextMenu, + preferredSubtitleLanguage = preferredSubtitleLanguage, ) } showPlaylistDialog.compose { itemId -> @@ -283,16 +219,6 @@ fun EpisodeDetails( 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 @@ -308,7 +234,7 @@ fun EpisodeDetailsContent( favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, canDelete: Boolean, - deleteOnClick: () -> Unit, + onConfirmDelete: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -346,6 +272,7 @@ fun EpisodeDetailsContent( .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandablePlayButtons( + title = ep.title ?: "", resumePosition = resumePosition, watched = dto.userData?.played ?: false, favorite = dto.userData?.isFavorite ?: false, @@ -367,7 +294,7 @@ fun EpisodeDetailsContent( trailers = null, trailerOnClick = {}, canDelete = canDelete, - deleteOnClick = deleteOnClick, + onConfirmDelete = onConfirmDelete, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index df3b180a..2110a3a5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -20,7 +20,6 @@ import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService 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.launchIO import com.github.damontecres.wholphin.ui.nav.Destination @@ -38,9 +37,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async 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.launch import kotlinx.coroutines.withContext @@ -81,13 +77,9 @@ class EpisodeViewModel init { init() viewModelScope.launchDefault { - item - .asFlow() - .filterNotNull() - .combinePair(userPreferencesService.flow.map { it.appPreferences }) - .collectLatest { (item, preferences) -> - canDelete.update { mediaManagementService.canDelete(item, preferences) } - } + mediaManagementService.collectCanDelete(item.asFlow()) { canDelete -> + this@EpisodeViewModel.canDelete.update { canDelete } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 64bf2f1c..e198689d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -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.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard -import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup +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.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage 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.components.PersonContextActions 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.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog 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.DiscoverRowData 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 kotlinx.coroutines.launch 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.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import kotlin.time.Duration @@ -101,11 +94,9 @@ fun MovieDetails( val state by viewModel.state.collectAsState() var overviewDialog by remember { mutableStateOf(null) } - var moreDialog by remember { mutableStateOf(null) } - var chooseVersion by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) - var showDeleteDialog by remember { mutableStateOf(null) } val preferredSubtitleLanguage = viewModel.serverRepository.currentUserDto @@ -114,22 +105,36 @@ fun MovieDetails( ?.configuration ?.subtitleLanguagePreference - val moreActions = - MoreDialogActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched(itemId, watched) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - onClickDelete = { showDeleteDialog = it }, - ) + val contextActions = + remember { + ContextMenuActions( + navigateTo = viewModel::navigateTo, + onClickWatch = viewModel::setWatched, + onClickFavorite = viewModel::setFavorite, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(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 = { viewModel.clearChosenStreams(it) }, + ) + } when (val s = state.loading) { is DataLoadingState.Error -> { @@ -183,72 +188,17 @@ fun MovieDetails( ItemDetailsDialogInfo(movie) }, moreOnClick = { - moreDialog = - DialogParams( + showContextMenu = + ContextMenu.ForBaseItem( fromLongClick = false, - title = movie.name + " (${movie.data.productionYear ?: ""})", - items = - buildMoreDialogItems( - context = context, - item = movie, - watched = movie.data.userData?.played ?: false, - favorite = movie.data.userData?.isFavorite ?: false, - seriesId = null, - 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) - }, - ), + item = movie, + chosenStreams = chosenStreams, + showGoTo = false, + showStreamChoices = true, + canDelete = state.canDelete, + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, ) }, watchOnClick = { @@ -258,36 +208,29 @@ fun MovieDetails( viewModel.setFavorite(movie.id, !movie.favorite) }, onLongClickPerson = { index, person -> - val items = - buildMoreDialogItemsForPerson( - context = context, - person = person, - actions = moreActions, - ) - moreDialog = - DialogParams( + showContextMenu = + ContextMenu.ForPerson( fromLongClick = true, - title = person.name ?: "", - items = items, + person = person, + actions = + PersonContextActions( + navigateTo = viewModel::navigateTo, + onClickFavorite = viewModel::setFavorite, + ), ) }, - onLongClickSimilar = { index, similar -> - val items = - buildMoreDialogItemsForHome( - context = context, - item = similar, - seriesId = null, - playbackPosition = similar.playbackPosition, - watched = similar.played, - favorite = similar.favorite, - canDelete = false, - actions = moreActions, - ) - moreDialog = - DialogParams( + onLongClickSimilar = { _, similar -> + showContextMenu = + ContextMenu.ForBaseItem( fromLongClick = true, - title = similar.title ?: "", - items = items, + item = similar, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = false, + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, ) }, trailerOnClick = { @@ -300,11 +243,19 @@ fun MovieDetails( viewModel.navigateTo(item.destination) }, canDelete = state.canDelete, - deleteOnClick = { showDeleteDialog = state.movie }, + onConfirmDelete = { state.movie?.let { viewModel.deleteItem(it) } }, modifier = modifier, ) } } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = viewModel.streamChoiceService::chooseSource, + contextMenu = contextMenu, + preferredSubtitleLanguage = preferredSubtitleLanguage, + ) + } overviewDialog?.let { info -> ItemDetailsDialog( info = info, @@ -315,26 +266,6 @@ fun MovieDetails( 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 -> PlaylistDialog( title = stringResource(R.string.add_to_playlist), @@ -352,16 +283,6 @@ fun MovieDetails( 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 @@ -390,7 +311,7 @@ fun MovieDetailsContent( onClickExtra: (Int, ExtrasItem) -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit, canDelete: Boolean, - deleteOnClick: () -> Unit, + onConfirmDelete: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -430,6 +351,7 @@ fun MovieDetailsContent( .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandablePlayButtons( + title = movie.title ?: "", resumePosition = resumePosition, watched = dto.userData?.played ?: false, favorite = dto.userData?.isFavorite ?: false, @@ -454,7 +376,7 @@ fun MovieDetailsContent( trailerOnClick.invoke(it) }, canDelete = canDelete, - deleteOnClick = deleteOnClick, + onConfirmDelete = onConfirmDelete, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 82518409..bd2cc373 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -42,7 +42,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.libraryApi @@ -85,18 +85,9 @@ class MovieViewModel init { init() viewModelScope.launchDefault { - userPreferencesService.flow.collectLatest { preferences -> + mediaManagementService.collectCanDelete(state.map { it.movie }) { canDelete -> _state.update { - val canDelete = - it.movie?.let { - mediaManagementService.canDelete( - it, - preferences.appPreferences, - ) - } - it.copy( - canDelete = canDelete ?: false, - ) + it.copy(canDelete = canDelete) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt index 53cdb444..8d374a0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -61,12 +61,12 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.ItemRow -import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.ContextMenu +import com.github.damontecres.wholphin.ui.components.ContextMenuDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreText 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.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetails @@ -94,6 +94,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient @@ -136,6 +137,11 @@ class AlbumViewModel init { init() + viewModelScope.launchDefault { + mediaManagementService.collectCanDelete(state.map { it.album }) { canDelete -> + _state.update { it.copy(canDelete = canDelete) } + } + } } override fun init() { @@ -275,6 +281,7 @@ data class AlbumState( val similar: List, val loading: LoadingState, val musicVideos: List = emptyList(), + val canDelete: Boolean = false, ) { companion object { val EMPTY = AlbumState(null, false, null, emptyList(), emptyList(), LoadingState.Pending) @@ -308,22 +315,21 @@ fun AlbumDetailsPage( val focusManager = LocalFocusManager.current var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) - var moreDialog by remember { mutableStateOf(null) } - var showDeleteDialog by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(null) } val moreDialogActions = remember { - MusicMoreDialogActions( - onNavigate = { viewModel.navigationManager.navigateTo(it) }, + MusicContextActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, onClickPlay = { index, _ -> viewModel.play(false, index) }, 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) }, onClickAddPlaylist = { itemId -> playlistViewModel.loadPlaylists(MediaType.AUDIO) showPlaylistDialog.makePresent(itemId) }, - onClickRemoveFromQueue = {}, - onClickDelete = { showDeleteDialog = it }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = viewModel::deleteItem, ) } when (val loading = state.loading) { @@ -390,6 +396,7 @@ fun AlbumDetailsPage( modifier = Modifier.fillMaxWidth(), ) MusicExpandableButtons( + title = album.title ?: "", actions = remember { MusicButtonActions( @@ -402,28 +409,27 @@ fun AlbumDetailsPage( ) }, onClickMore = { - moreDialog = - DialogParams( + showContextMenu = + ContextMenu.ForMusic( fromLongClick = false, - title = album.name + " (${album.data.productionYear ?: ""})", - items = - buildMoreDialogForMusic( - context = context, - actions = moreDialogActions, - item = album, - index = 0, - canRemove = false, - canDelete = - viewModel.canDelete( - album, - preferences.appPreferences, - ), + item = album, + index = 0, + canDelete = + viewModel.canDelete( + album, + preferences.appPreferences, ), + canRemoveFromQueue = false, + actions = moreDialogActions, ) }, + onConfirmDelete = { + viewModel.deleteItem(album) + }, ) }, favorite = album.favorite, + canDelete = state.canDelete, buttonOnFocusChanged = { if (it.isFocused) { position = RowColumn(HEADER_ROW, 0) @@ -459,45 +465,35 @@ fun AlbumDetailsPage( }, onLongClick = { if (song != null) { - moreDialog = - DialogParams( + showContextMenu = + ContextMenu.ForMusic( fromLongClick = true, - title = song.name ?: "", - items = - buildMoreDialogForMusic( - context = context, - actions = moreDialogActions, - item = song, - index = index, - canRemove = false, - canDelete = - viewModel.canDelete( - song, - preferences.appPreferences, - ), + item = song, + index = index, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, ), + canRemoveFromQueue = false, + actions = moreDialogActions, ) } }, onClickMore = { if (song != null) { - moreDialog = - DialogParams( + showContextMenu = + ContextMenu.ForMusic( fromLongClick = false, - title = song.name ?: "", - items = - buildMoreDialogForMusic( - context = context, - actions = moreDialogActions, - item = song, - index = index, - canRemove = false, - canDelete = - viewModel.canDelete( - song, - preferences.appPreferences, - ), + item = song, + index = index, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, ), + canRemoveFromQueue = false, + actions = moreDialogActions, ) } }, @@ -559,23 +555,18 @@ fun AlbumDetailsPage( viewModel.navigationManager.navigateTo(item.destination()) }, onLongClickItem = { index, item -> - moreDialog = - DialogParams( + showContextMenu = + ContextMenu.ForMusic( fromLongClick = true, - title = item.name ?: "", - items = - buildMoreDialogForMusic( - context = context, - actions = moreDialogActions, - item = item, - index = index, - canRemove = false, - canDelete = - viewModel.canDelete( - item, - preferences.appPreferences, - ), + item = item, + index = index, + canDelete = + viewModel.canDelete( + item, + preferences.appPreferences, ), + canRemoveFromQueue = false, + actions = moreDialogActions, ) }, cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> @@ -597,14 +588,12 @@ fun AlbumDetailsPage( } } } - moreDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { moreDialog = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, ) } showPlaylistDialog.compose { itemId -> @@ -624,17 +613,6 @@ fun AlbumDetailsPage( 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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt index 4a338d03..1f693322 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt @@ -59,12 +59,12 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.ItemRow -import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.ContextMenu +import com.github.damontecres.wholphin.ui.components.ContextMenuDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreText 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.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetails @@ -92,6 +92,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient @@ -135,6 +136,11 @@ class ArtistViewModel init { init() + viewModelScope.launchDefault { + mediaManagementService.collectCanDelete(state.map { it.artist }) { canDelete -> + _state.update { it.copy(canDelete = canDelete) } + } + } } override fun init() { @@ -269,6 +275,7 @@ data class ArtistState( val similar: List, val loading: LoadingState, val musicVideos: List, + val canDelete: Boolean, ) { companion object { val EMPTY = @@ -280,6 +287,7 @@ data class ArtistState( emptyList(), LoadingState.Pending, emptyList(), + false, ) } } @@ -312,22 +320,21 @@ fun ArtistDetailsPage( var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) - var moreDialog by remember { mutableStateOf(null) } - var showDeleteDialog by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(null) } val moreDialogActions = remember { - MusicMoreDialogActions( - onNavigate = { viewModel.navigationManager.navigateTo(it) }, + MusicContextActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, onClickPlay = { index, item -> viewModel.play(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) }, onClickAddPlaylist = { itemId -> playlistViewModel.loadPlaylists(MediaType.AUDIO) showPlaylistDialog.makePresent(itemId) }, - onClickRemoveFromQueue = {}, - onClickDelete = { showDeleteDialog = it }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = viewModel::deleteItem, ) } @@ -379,6 +386,7 @@ fun ArtistDetailsPage( modifier = Modifier.fillMaxWidth(), ) MusicExpandableButtons( + title = artist.title ?: "", actions = remember { MusicButtonActions( @@ -393,28 +401,25 @@ fun ArtistDetailsPage( ) }, onClickMore = { - moreDialog = - DialogParams( + showContextMenu = + ContextMenu.ForMusic( fromLongClick = false, - title = artist.name ?: "", - items = - buildMoreDialogForMusic( - context = context, - actions = moreDialogActions, - item = artist, - index = 0, - canRemove = false, - canDelete = - viewModel.canDelete( - artist, - preferences.appPreferences, - ), + item = artist, + index = 0, + canDelete = + viewModel.canDelete( + artist, + preferences.appPreferences, ), + canRemoveFromQueue = false, + actions = moreDialogActions, ) }, + onConfirmDelete = { viewModel.deleteItem(artist) }, ) }, favorite = artist.favorite, + canDelete = state.canDelete, buttonOnFocusChanged = { if (it.isFocused) { position = RowColumn(HEADER_ROW, 0) @@ -451,45 +456,35 @@ fun ArtistDetailsPage( }, onLongClick = { if (song != null) { - moreDialog = - DialogParams( + showContextMenu = + ContextMenu.ForMusic( fromLongClick = true, - title = song.name ?: "", - items = - buildMoreDialogForMusic( - context = context, - actions = moreDialogActions, - item = song, - index = index, - canRemove = false, - canDelete = - viewModel.canDelete( - song, - preferences.appPreferences, - ), + item = song, + index = index, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, ), + canRemoveFromQueue = false, + actions = moreDialogActions, ) } }, onClickMore = { if (song != null) { - moreDialog = - DialogParams( + showContextMenu = + ContextMenu.ForMusic( fromLongClick = false, - title = song.name ?: "", - items = - buildMoreDialogForMusic( - context = context, - actions = moreDialogActions, - item = song, - index = index, - canRemove = false, - canDelete = - viewModel.canDelete( - song, - preferences.appPreferences, - ), + item = song, + index = index, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, ), + canRemoveFromQueue = false, + actions = moreDialogActions, ) } }, @@ -516,23 +511,18 @@ fun ArtistDetailsPage( viewModel.navigationManager.navigateTo(album.destination()) }, onLongClickItem = { index, album -> - moreDialog = - DialogParams( + showContextMenu = + ContextMenu.ForMusic( fromLongClick = true, - title = album.name ?: "", - items = - buildMoreDialogForMusic( - context = context, - actions = moreDialogActions, - item = album, - index = index, - canRemove = false, - canDelete = - viewModel.canDelete( - album, - preferences.appPreferences, - ), + item = album, + index = index, + canDelete = + viewModel.canDelete( + album, + preferences.appPreferences, ), + canRemoveFromQueue = false, + actions = moreDialogActions, ) }, cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> @@ -588,23 +578,18 @@ fun ArtistDetailsPage( viewModel.navigationManager.navigateTo(item.destination()) }, onLongClickItem = { index, item -> - moreDialog = - DialogParams( + showContextMenu = + ContextMenu.ForMusic( fromLongClick = true, - title = item.name ?: "", - items = - buildMoreDialogForMusic( - context = context, - actions = moreDialogActions, - item = item, - index = index, - canRemove = false, - canDelete = - viewModel.canDelete( - item, - preferences.appPreferences, - ), + item = item, + index = index, + canDelete = + viewModel.canDelete( + item, + preferences.appPreferences, ), + canRemoveFromQueue = false, + actions = moreDialogActions, ) }, cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> @@ -626,14 +611,12 @@ fun ArtistDetailsPage( } } } - moreDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { moreDialog = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, ) } showPlaylistDialog.compose { itemId -> @@ -653,17 +636,6 @@ fun ArtistDetailsPage( 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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt index 78a5603b..b93483c9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt @@ -18,14 +18,17 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp 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.ExpandablePlayButton import kotlin.time.Duration @Composable fun MusicExpandableButtons( + title: String, actions: MusicButtonActions, favorite: Boolean, + canDelete: Boolean, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { @@ -75,6 +78,15 @@ fun MusicExpandableButtons( modifier = Modifier.onFocusChanged(buttonOnFocusChanged), ) } + if (canDelete) { + item("delete") { + DeleteButton( + title = title, + onConfirmDelete = actions.onConfirmDelete, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + } item("more") { ExpandablePlayButton( title = R.string.more, @@ -92,4 +104,5 @@ data class MusicButtonActions( val onClickInstantMix: () -> Unit, val onClickFavorite: () -> Unit, val onClickMore: () -> Unit, + val onConfirmDelete: () -> Unit, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt index 44f08012..1d976ede 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt @@ -1,7 +1,6 @@ package com.github.damontecres.wholphin.ui.detail.music import androidx.annotation.OptIn -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup 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.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -22,7 +20,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource 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.rememberRepeatButtonState import androidx.media3.ui.compose.state.rememberShuffleButtonState -import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.MaterialTheme 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.components.Button import com.github.damontecres.wholphin.ui.playback.ControllerViewState -import com.github.damontecres.wholphin.ui.playback.PlaybackAction -import com.github.damontecres.wholphin.ui.playback.PlaybackButton -import com.github.damontecres.wholphin.ui.playback.PlaybackButtons -import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType -import com.github.damontecres.wholphin.ui.playback.buttonSpacing +import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction +import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton +import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButtons +import com.github.damontecres.wholphin.ui.playback.overlay.buttonSpacing import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource import com.github.damontecres.wholphin.ui.theme.WholphinTheme import kotlin.time.Duration.Companion.seconds diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt index d76e560e..a340f66c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt @@ -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.main.settings.MoveDirection 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.roundSeconds import com.github.damontecres.wholphin.ui.tryRequestFocus diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index 1f253b82..a9d43a27 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -60,15 +60,16 @@ import com.github.damontecres.wholphin.preferences.BackdropStyle import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.rememberQueue import com.github.damontecres.wholphin.ui.components.BasicDialog -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.ContextMenu +import com.github.damontecres.wholphin.ui.components.ContextMenuDialog 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.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.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.util.LoadingState 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 itemMoreDialog by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(null) } + var lyricsHaveFocus by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } @@ -209,6 +201,7 @@ fun NowPlayingPage( remember { shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut() } + //noinspection UnnecessaryFullyQualifiedName androidx.compose.animation.AnimatedVisibility( visible = musicPrefs.showAlbumArt, enter = enter, @@ -250,6 +243,7 @@ fun NowPlayingPage( } } } + //noinspection UnnecessaryFullyQualifiedName androidx.compose.animation.AnimatedVisibility( visible = musicPrefs.showVisualizer, enter = enter, @@ -308,17 +302,21 @@ fun NowPlayingPage( val showContextForItem = remember { { fromLongClick: Boolean, index: Int, song: AudioItem -> - itemMoreDialog = - DialogParams( - title = song.title ?: "", + showContextMenu = + ContextMenu.ForQueue( fromLongClick = fromLongClick, - items = - buildMoreDialogForMusicQueue( - context = context, - actions = actions, - item = song, - index = index, - canRemove = true, + item = song, + index = index, + actions = + QueueContextActions( + onNavigate = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { index, _ -> viewModel.play(index) }, + onClickPlayNext = { index, _ -> viewModel.playNext(index) }, + onClickRemoveFromQueue = { index, _ -> + viewModel.removeFromQueue( + index, + ) + }, ), ) } @@ -368,14 +366,12 @@ fun NowPlayingPage( LoadingPage(focusEnabled = false) } } - itemMoreDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { itemMoreDialog = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, ) } if (showViewOptionsDialog) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt index 5fae787c..8e170706 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt @@ -1,20 +1,8 @@ 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.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.components.DialogItem -import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetItemsRequestHandler 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 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 = - 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 = - 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( api: ApiClient, albumId: UUID, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt index 2aeac04e..83987524 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -26,7 +26,7 @@ fun FocusedEpisodeFooter( watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, canDelete: Boolean, - deleteOnClick: () -> Unit, + onConfirmDelete: () -> Unit, modifier: Modifier = Modifier, buttonOnFocusChanged: (FocusState) -> Unit = {}, ) { @@ -39,6 +39,7 @@ fun FocusedEpisodeFooter( modifier = modifier, ) { ExpandablePlayButtons( + title = ep.title ?: "", resumePosition = resumePosition, watched = dto.userData?.played ?: false, favorite = dto.userData?.isFavorite ?: false, @@ -50,7 +51,7 @@ fun FocusedEpisodeFooter( trailers = null, trailerOnClick = {}, canDelete = canDelete, - deleteOnClick = deleteOnClick, + onConfirmDelete = onConfirmDelete, modifier = Modifier.fillMaxWidth(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 3c6b7d5c..18a539c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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.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.PersonRow 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.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.DialogItem 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.ExpandableFaButton 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.Optional 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.TitleOrLogo import com.github.damontecres.wholphin.ui.components.TrailerButton 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.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog 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.DiscoverRowData -import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt @@ -95,6 +92,7 @@ import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID import kotlin.time.Duration @@ -128,10 +126,50 @@ fun SeriesDetails( val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var overviewDialog by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(null) } + var showWatchConfirmation by remember { mutableStateOf(false) } - var seasonDialog by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - var showDeleteDialog by remember { mutableStateOf(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) { viewModel.refresh() @@ -185,29 +223,18 @@ fun SeriesDetails( ) }, onLongClickItem = { index, season -> - scope.launchDefault { - seasonDialog = - buildDialogForSeason( - context = context, - s = season, - canDelete = viewModel.canDelete(season), - onClickItem = { viewModel.navigateTo(it.destination()) }, - markPlayed = { played -> - viewModel.setSeasonWatched(season.id, played) - }, - onClickPlay = { shuffle -> - viewModel.navigateTo( - Destination.PlaybackList( - itemId = season.id, - shuffle = shuffle, - ), - ) - }, - onClickDelete = { - showDeleteDialog = it - }, - ) - } + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, + item = season, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = viewModel.canDelete(season, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) }, overviewOnClick = { overviewDialog = ItemDetailsDialogInfo(item) @@ -245,24 +272,10 @@ fun SeriesDetails( onClickDiscover = { index, item -> viewModel.navigateTo(item.destination) }, - moreActions = - MoreDialogActions( - navigateTo = { viewModel.navigateTo(it) }, - onClickWatch = { itemId, played -> - 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 - }, - ), + onShowContextMenu = { + showContextMenu = it + }, + actions = contextActions, ) if (showWatchConfirmation) { 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 -> ItemDetailsDialog( info = info, @@ -288,15 +309,6 @@ fun SeriesDetails( onDismissRequest = { overviewDialog = null }, ) } - seasonDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - waitToLoad = params.fromLongClick, - onDismissRequest = { seasonDialog = null }, - ) - } showPlaylistDialog.compose { itemId -> PlaylistDialog( title = stringResource(R.string.add_to_playlist), @@ -314,19 +326,6 @@ fun SeriesDetails( 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 @@ -359,7 +358,8 @@ fun SeriesDetailsContent( favoriteOnClick: () -> Unit, trailerOnClick: (Trailer) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, - moreActions: MoreDialogActions, + onShowContextMenu: (ContextMenu) -> Unit, + actions: ContextMenuActions, onClickDiscover: (Int, DiscoverItem) -> Unit, discoverSeries: DiscoverItem?, onClickDiscoverSeries: () -> Unit, @@ -373,7 +373,6 @@ fun SeriesDetailsContent( val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } } val playFocusRequester = remember { FocusRequester() } RequestOrRestoreFocus(focusRequesters.getOrNull(position)) - var moreDialog by remember { mutableStateOf(null) } Box( modifier = modifier, @@ -486,9 +485,10 @@ fun SeriesDetailsContent( ) if (canDelete) { DeleteButton( - onClick = { + title = series.title ?: "", + onConfirmDelete = { position = HEADER_ROW - moreActions.onClickDelete.invoke(series) + actions.onDeleteItem.invoke(series) }, modifier = Modifier @@ -561,18 +561,17 @@ fun SeriesDetailsContent( }, onLongClick = { index, person -> position = PEOPLE_ROW - val items = - buildMoreDialogItemsForPerson( - context = context, - person = person, - actions = moreActions, - ) - moreDialog = - DialogParams( + onShowContextMenu.invoke( + ContextMenu.ForPerson( fromLongClick = true, - title = person.name ?: "", - items = items, - ) + person = person, + actions = + PersonContextActions( + navigateTo = actions.navigateTo, + onClickFavorite = actions.onClickFavorite, + ), + ), + ) }, modifier = Modifier @@ -608,23 +607,19 @@ fun SeriesDetailsContent( }, onLongClickItem = { index, item -> position = SIMILAR_ROW - val items = - buildMoreDialogItemsForHome( - context = context, - item = item, - seriesId = null, - playbackPosition = item.playbackPosition, - watched = item.played, - favorite = item.favorite, - actions = moreActions, - canDelete = false, - ) - moreDialog = - DialogParams( + onShowContextMenu.invoke( + ContextMenu.ForBaseItem( fromLongClick = true, - title = item.name ?: "", - items = items, - ) + item = item, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = false, + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = actions, + ), + ) }, cardContent = { index, item, mod, onClick, onLongClick -> 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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 23633651..6d99e156 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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.map 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.ui.RequestOrRestoreFocus -import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup +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.ErrorMessage 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.ItemDetailsDialog 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.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.rememberInt -import com.github.damontecres.wholphin.ui.seasonEpisode -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers 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.PersonKind import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.toUUID -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import kotlin.time.Duration @@ -98,16 +88,19 @@ fun SeriesOverview( val episodeRowFocusRequester = remember { FocusRequester() } val castCrewRowFocusRequester = remember { FocusRequester() } val guestStarRowFocusRequester = remember { FocusRequester() } + val extrasRowFocusRequester = remember { FocusRequester() } val loading by viewModel.loading.observeAsState(LoadingState.Loading) 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 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 position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0)) + val currentPosition by rememberUpdatedState(position) LaunchedEffect(Unit) { if (seasons.isNotEmpty()) { seasons.getOrNull(position.seasonTabIndex)?.let { @@ -117,13 +110,54 @@ fun SeriesOverview( } var overviewDialog by remember { mutableStateOf(null) } - var moreDialog by remember { mutableStateOf(null) } - var chooseVersion by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var rowFocused by rememberInt() - var showDeleteDialog by remember { mutableStateOf(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) { val focusedEpisode = @@ -164,6 +198,7 @@ fun SeriesOverview( EPISODE_ROW -> episodeRowFocusRequester CAST_AND_CREW_ROW -> castCrewRowFocusRequester GUEST_STAR_ROW -> guestStarRowFocusRequester + EXTRAS_ROW -> extrasRowFocusRequester else -> episodeRowFocusRequester }, "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( preferences = preferences, series = series, @@ -280,11 +218,13 @@ fun SeriesOverview( episodes = episodes, chosenStreams = chosenStreams, peopleInEpisode = peopleInEpisode, + seasonExtras = seasonExtras, position = position, firstItemFocusRequester = firstItemFocusRequester, episodeRowFocusRequester = episodeRowFocusRequester, castCrewRowFocusRequester = castCrewRowFocusRequester, guestStarRowFocusRequester = guestStarRowFocusRequester, + extrasRowFocusRequester = extrasRowFocusRequester, onChangeSeason = { index -> if (index != position.seasonTabIndex) { seasons.getOrNull(index)?.let { season -> @@ -314,9 +254,18 @@ fun SeriesOverview( ) }, onLongClick = { ep -> - scope.launchDefault { - moreDialog = buildMoreForEpisode(ep, chosenStreams, true) - } + showContextMenu = + 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 -> rowFocused = EPISODE_ROW @@ -344,9 +293,18 @@ fun SeriesOverview( }, moreOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> - scope.launchDefault { - moreDialog = buildMoreForEpisode(ep, chosenStreams, false) - } + showContextMenu = + 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 = { @@ -364,14 +322,25 @@ fun SeriesOverview( ), ) }, + onClickExtra = { _, extra -> + rowFocused = EXTRAS_ROW + viewModel.navigateTo(extra.destination) + }, canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, - deleteOnClick = { showDeleteDialog = it }, + onConfirmDelete = viewModel::deleteItem, modifier = modifier, ) } } } - + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = viewModel.streamChoiceService::chooseSource, + contextMenu = contextMenu, + preferredSubtitleLanguage = preferredSubtitleLanguage, + ) + } overviewDialog?.let { info -> ItemDetailsDialog( info = info, @@ -382,26 +351,6 @@ fun SeriesOverview( 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 -> PlaylistDialog( title = stringResource(R.string.add_to_playlist), @@ -419,19 +368,9 @@ fun SeriesOverview( 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 CAST_AND_CREW_ROW = EPISODE_ROW + 1 private const val GUEST_STAR_ROW = CAST_AND_CREW_ROW + 1 +private const val EXTRAS_ROW = GUEST_STAR_ROW + 1 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 34a9539a..999ed7c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.focusRestorer @@ -47,11 +46,13 @@ 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.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.AspectRatios 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.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.HeaderUtils @@ -74,6 +75,7 @@ fun SeriesOverviewContent( series: BaseItem, seasons: List, episodes: EpisodeList, + seasonExtras: List, chosenStreams: ChosenStreams?, peopleInEpisode: List, position: SeriesOverviewPosition, @@ -81,6 +83,7 @@ fun SeriesOverviewContent( episodeRowFocusRequester: FocusRequester, castCrewRowFocusRequester: FocusRequester, guestStarRowFocusRequester: FocusRequester, + extrasRowFocusRequester: FocusRequester, onChangeSeason: (Int) -> Unit, onFocusEpisode: (Int) -> Unit, onClick: (BaseItem) -> Unit, @@ -92,7 +95,8 @@ fun SeriesOverviewContent( overviewOnClick: () -> Unit, personOnClick: (Person) -> Unit, canDelete: (BaseItem) -> Boolean, - deleteOnClick: (BaseItem) -> Unit, + onConfirmDelete: (BaseItem) -> Unit, + onClickExtra: (Int, ExtrasItem) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -318,7 +322,7 @@ fun SeriesOverviewContent( } }, canDelete = canDelete.invoke(ep), - deleteOnClick = { deleteOnClick.invoke(ep) }, + onConfirmDelete = { onConfirmDelete.invoke(ep) }, modifier = Modifier .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), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index de04149b..4d0ce9e2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -59,6 +59,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -138,7 +139,11 @@ class SeriesViewModel Timber.v("Start") addCloseable { themeSongPlayer.stop() } val item = fetchItem(seriesId) - canDeleteSeries.update { mediaManagementService.canDelete(item) } + viewModelScope.launchDefault { + mediaManagementService.collectCanDelete(flowOf(item)) { canDelete -> + canDeleteSeries.update { canDelete } + } + } backdropService.submit(item) val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) @@ -184,6 +189,10 @@ class SeriesViewModel it.copy(seasonTabIndex = index.coerceAtLeast(0)) } } + viewModelScope.launchIO { + val extras = extrasService.getExtras(seasonEpisodeIds.seasonId) + this@SeriesViewModel.extras.setValueOnMain(extras) + } } val remoteTrailers = trailerService.getRemoteTrailers(item) withContext(Dispatchers.Main) { @@ -388,6 +397,7 @@ class SeriesViewModel if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { this@SeriesViewModel.peopleInEpisode.value = PeopleInItem() this@SeriesViewModel.episodes.value = EpisodeList.Loading + this@SeriesViewModel.extras.value = emptyList() } viewModelScope.launchIO(ExceptionHandler(true)) { val episodes = @@ -401,6 +411,10 @@ class SeriesViewModel this@SeriesViewModel.episodes.value = episodes } } + viewModelScope.launchIO { + val extras = extrasService.getExtras(seasonId) + this@SeriesViewModel.extras.setValueOnMain(extras) + } } fun setWatched( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt index b3460265..aa463ad6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt @@ -182,7 +182,7 @@ fun DiscoverRequestGrid( gridFocusRequester = gridFocusRequester, showJumpButtons = false, showLetterButtons = false, - cardContent = { item, onClick, onLongClick, mod -> + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> DiscoverItemCard( item = item, onClick = onClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt index 6ed79a06..d83f840b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt @@ -204,7 +204,7 @@ fun SeerrRequestsPage( showJumpButtons = false, showLetterButtons = false, spacing = 16.dp, - cardContent = @Composable { item, onClick, onLongClick, mod -> + cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) -> DiscoverItemCard( item = item?.item, onClick = onClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index ef0fdbad..a3b32382 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -31,9 +31,11 @@ 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.input.key.KeyEvent import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext 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.StudioCard import com.github.damontecres.wholphin.ui.components.CircularProgress -import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup +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.EpisodeName import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.FocusableItemRow import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage 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.rememberLogoUrl 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.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog 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.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination @@ -120,9 +121,10 @@ fun HomePage( } LoadingState.Success -> { - var dialog by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf(null) } - var showDeleteDialog by remember { mutableStateOf(null) } + var overviewDialog by remember { mutableStateOf(null) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var position by rememberPosition() @@ -144,43 +146,46 @@ fun HomePage( row?.rowType is HomeRowConfig.ContinueWatching || row?.rowType is HomeRowConfig.ContinueWatchingCombined val canRemoveNextUp = row?.rowType is HomeRowConfig.NextUp || row?.rowType is HomeRowConfig.ContinueWatchingCombined - val dialogItems = - buildMoreDialogItemsForHome( - context = context, + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, item = item, - seriesId = item.data.seriesId, - playbackPosition = item.playbackPosition, - watched = item.played, - favorite = item.favorite, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - canRemoveNextUp = canRemoveNextUp, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = + viewModel.canDelete( + item, + preferences.appPreferences, + ), canRemoveContinueWatching = canRemoveContinueWatching, + canRemoveNextUp = canRemoveNextUp, actions = - MoreDialogActions( + ContextMenuActions( navigateTo = viewModel.navigationManager::navigateTo, - onClickWatch = { itemId, played -> - viewModel.setWatched(itemId, played) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(itemId, favorite) - }, + onClickWatch = viewModel::setWatched, + onClickFavorite = viewModel::setFavorite, onClickAddPlaylist = { itemId -> playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog = itemId }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - onClickDelete = { - showDeleteDialog = RowColumnItem(position, item) + onDeleteItem = { + viewModel.deleteItem(position, it) }, + onChooseVersion = { _, _ -> + // Not supported on this page + }, + onChooseTracks = { + // Not supported on this page + }, + onShowOverview = { + overviewDialog = ItemDetailsDialogInfo(it) + }, + onClearChosenStreams = {}, onClickRemoveFromNextUp = viewModel::removeFromNextUp, ), ) - dialog = - DialogParams( - title = item.title ?: "", - fromLongClick = true, - items = dialogItems, - ) } } val onClickPlay = @@ -203,10 +208,19 @@ fun HomePage( showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = modifier, ) - dialog?.let { params -> - DialogPopup( - params = params, - onDismissRequest = { dialog = null }, + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = false, + onDismissRequest = { overviewDialog = null }, + ) + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, ) } showPlaylistDialog?.let { itemId -> @@ -226,19 +240,7 @@ fun HomePage( 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) } } Box(modifier = modifier) { - Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .focusProperties { + onEnter = { + rowFocusRequesters.getOrNull(currentPosition.row)?.tryRequestFocus() + } + }.fillMaxSize(), + ) { headerComposable.invoke(focusedItem) val density = LocalDensity.current @@ -406,7 +416,7 @@ fun HomePageContent( } val onKey = remember(item) { - { event: androidx.compose.ui.input.key.KeyEvent -> + { event: KeyEvent -> if (isPlayKeyUp(event) && item?.type?.playable == true) { Timber.v("Clicked play on ${item.id}") currentOnClickPlay( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 6d1517c2..d3722331 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.select import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext @@ -87,26 +88,30 @@ class HomeViewModel // Refreshing if a load has already occurred and the rows haven't significantly changed val refresh = 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 { it.copy( loadingState = if (refresh) LoadingState.Success else LoadingState.Loading, refreshState = LoadingState.Loading, settings = settings, + homeRows = + if (refresh) { + it.homeRows + } else { + List(settings.rows.size) { HomeRowLoadingState.Pending("") } + }, ) } val semaphore = Semaphore(4) - val watchingRowIndexes = - settings.rows - .mapIndexedNotNull { index, row -> - if (isWatchingRow(row.config)) index else null - } val deferred = settings.rows - // Load the watching rows first - .sortedByDescending { isWatchingRow(it.config) } .map { row -> viewModelScope.async(Dispatchers.IO) { semaphore.withPermit { @@ -123,53 +128,63 @@ class HomeViewModel ) } catch (ex: Exception) { 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()) { - // Replace watching rows first - Timber.v("Refreshing rows: %s", watchingRowIndexes) - val rows = - deferred - .filterIndexed { index, _ -> index in watchingRowIndexes } - .awaitAll() - _state.update { - val newRows = - it.homeRows.toMutableList().apply { - rows.forEachIndexed { index, row -> - set(watchingRowIndexes[index], row) - } + if (refresh) { + // Replace rows as they complete + val remaining = deferred.withIndex().toMutableList() + while (remaining.isNotEmpty()) { + val (rowIndex, rowData) = + select { + // "Return" the first remaining that is completed + remaining + .forEach { (rowIndex, deferred) -> + deferred.onAwait { rowIndex to it } + } } + 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( 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 = - 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, - ) - } + Timber.d("Home page load complete") } } catch (ex: Exception) { Timber.e(ex, "Exception during home page loading") if (state.value.loadingState == LoadingState.Success) { showToast(context, "Error refreshing home: ${ex.localizedMessage}") + _state.update { it.copy(refreshState = LoadingState.Error(ex)) } } else { _state.update { it.copy(loadingState = LoadingState.Error(ex)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index a7559914..9aed36cd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -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.DiscoverItem 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.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption @@ -78,8 +78,8 @@ sealed class Destination( data class Playback( val itemId: UUID, val positionMs: Long, - val itemPlayback: ItemPlayback? = null, val forceTranscoding: Boolean = false, + val backend: PlayerBackend? = null, ) : Destination(true) { constructor(item: BaseItem) : this(item.id, item.resumeMs) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index f6f3faa7..22dfc570 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -76,9 +76,25 @@ fun DestinationContent( 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) { PlayExternalPage( preferences = preferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index c14d184d..915fc5a7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -8,14 +8,13 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.platform.LocalContext import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.ServerRepository 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.services.NavigationManager 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.ui.components.ErrorMessage 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.isNotNullOrBlank 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.showToast 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.qualifiers.ApplicationContext 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.videosApi 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.RepeatMode import org.jellyfin.sdk.model.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.io.File import java.util.UUID +import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds -@HiltViewModel(assistedFactory = PlayExternalViewModel.Factory::class) +@HiltViewModel class PlayExternalViewModel - @AssistedInject + @Inject constructor( private val savedStateHandle: SavedStateHandle, @param:ApplicationContext private val context: Context, @@ -75,16 +78,13 @@ class PlayExternalViewModel private val streamChoiceService: StreamChoiceService, private val navigationManager: NavigationManager, private val userPreferencesService: UserPreferencesService, - @Assisted val destination: Destination, ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(destination: Destination): PlayExternalViewModel - } - + val launched = savedStateHandle.getMutableStateFlow("launched", false) 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 { val prefs = userPreferencesService.getCurrent() val positionMs: Long @@ -106,9 +106,9 @@ class PlayExternalViewModel } try { val queriedItem = api.userLibraryApi.getItem(itemId).content - val base = + val playlistItem = if (queriedItem.type.playable) { - queriedItem + PlaylistItem.Media(BaseItem(queriedItem)) } else if (destination is Destination.PlaybackList) { val playlistResult = playlistCreator.createFrom( @@ -135,18 +135,15 @@ class PlayExternalViewModel navigationManager.goBack() return@launchDefault } - r.playlist.items - .first() - .data + r.playlist.items.first() } } } else { throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } - val item = BaseItem(base, false) val playbackConfig = 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) if (it.sourceId != null) { it @@ -155,20 +152,25 @@ class PlayExternalViewModel } } } - val mediaSource = streamChoiceService.chooseSource(base, playbackConfig) - val plc = streamChoiceService.getPlaybackLanguageChoice(base) + val item = + 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) { Timber.w("Media source is null") return@launchDefault } - savedStateHandle[KEY_ID] = base.id + savedStateHandle[KEY_ID] = playlistItem.id savedStateHandle[KEY_MEDIA_ID] = mediaSource.id val subtitleIndex = streamChoiceService .chooseSubtitleStream( source = mediaSource, audioStream = null, - seriesId = base.seriesId, + seriesId = item.data.seriesId, itemPlayback = playbackConfig, plc = plc, prefs = prefs, @@ -252,7 +254,17 @@ class PlayExternalViewModel 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 { PlayExternalState( loading = LoadingState.Success, @@ -278,10 +290,10 @@ class PlayExternalViewModel return@launchDefault } Timber.v( - "Result: result=%s, itemId=%s action=%s", + "Result: result=%s, action=%s, itemId=%s", result.resultCode, - itemId, result.data?.action, + itemId, ) if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED || // Vimu return 1 for video completion @@ -295,7 +307,7 @@ class PlayExternalViewModel position = data .getLongExtra("extra_position", Long.MIN_VALUE) - .takeIf { it >= 0 } + .takeIf { it > 0 } } // mpv-android: https://mpv-android.github.io/mpv-android/intent.html @@ -326,19 +338,27 @@ class PlayExternalViewModel } } Timber.v("Result position: %s", position?.milliseconds) - api.playStateApi.reportPlaybackStopped( - PlaybackStopInfo( - itemId = itemId, - mediaSourceId = mediaSourceId, - positionTicks = position?.milliseconds?.inWholeTicks, - failed = false, - ), - ) + if (position != null || result.data?.action != null) { + api.playStateApi.reportPlaybackStopped( + PlaybackStopInfo( + itemId = itemId, + mediaSourceId = mediaSourceId, + positionTicks = position?.milliseconds?.inWholeTicks, + failed = false, + ), + ) + } } 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") } navigationManager.goBack() + state.update { PlayExternalState() } + launched.update { false } } catch (_: CancellationException) { } catch (ex: Exception) { Timber.e(ex, "Error during external playback of %s", itemId) @@ -359,7 +379,7 @@ class PlayExternalViewModel } data class PlayExternalState( - val loading: LoadingState = LoadingState.Loading, + val loading: LoadingState = LoadingState.Pending, val intent: Intent = Intent(), ) @@ -369,8 +389,8 @@ fun PlayExternalPage( destination: Destination, modifier: Modifier = Modifier, viewModel: PlayExternalViewModel = - hiltViewModel( - creationCallback = { it.create(destination) }, + hiltViewModel( + viewModelStoreOwner = LocalContext.current.findActivity() as AppCompatActivity, ), ) { val launcher = @@ -380,15 +400,18 @@ fun PlayExternalPage( ) val state by viewModel.state.collectAsState() - var launched by rememberSaveable { mutableStateOf(false) } - if (!launched) { - LaunchedEffect(Unit) { - viewModel.init() + val launched by viewModel.launched.collectAsState() + LaunchedEffect(Unit) { + if (!launched) { + viewModel.init(destination) } } when (val l = state.loading) { - LoadingState.Pending, + LoadingState.Pending -> { + LoadingPage(modifier, false) + } + LoadingState.Loading, -> { LoadingPage(modifier) @@ -403,7 +426,7 @@ fun PlayExternalPage( if (!launched) { LifecycleStartEffect(Unit) { Timber.i("Launching external playback") - launched = true + viewModel.launched.update { true } try { launcher.launch(state.intent) } catch (ex: Exception) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt deleted file mode 100644 index e756a9df..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt +++ /dev/null @@ -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(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>, - 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, - ) - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 1968fac3..5b0bd5d8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -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.components.SelectedLeadingContent 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 kotlin.time.Duration enum class PlaybackDialogType { - MORE, + DEBUG, CAPTIONS, SETTINGS, AUDIO, @@ -65,10 +68,10 @@ data class PlaybackSettings( /** * 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. * - * @see PlaybackAction + * @see com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction */ @Composable fun PlaybackDialog( @@ -83,28 +86,8 @@ fun PlaybackDialog( onChangeSubtitleDelay: (Duration) -> Unit, ) { when (type) { - PlaybackDialogType.MORE -> { - val options = - 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.DEBUG -> { + throw IllegalStateException("Should not open a dialog with " + PlaybackDialogType.DEBUG) } PlaybackDialogType.CAPTIONS -> { @@ -135,17 +118,8 @@ fun PlaybackDialog( } PlaybackDialogType.SETTINGS -> { - val currentAudio = - remember(settings) { settings.audioStreams.firstOrNull { it.index == settings.audioIndex } } val options = buildList { - add( - BottomDialogItem( - data = PlaybackDialogType.AUDIO, - headline = stringResource(R.string.audio), - supporting = currentAudio?.displayTitle, - ), - ) add( BottomDialogItem( 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( choices = options, currentChoice = null, onDismissRequest = onDismissRequest, 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 = { onControllerInteraction.invoke() onDismissRequest.invoke() -// scope.launch { -// delay(250L) -// settingsFocusRequester.tryRequestFocus() -// } }, onSelectChoice = { _, choice -> onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index)) @@ -220,15 +201,11 @@ fun PlaybackDialog( onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() -// scope.launch { -// delay(250L) -// settingsFocusRequester.tryRequestFocus() -// } }, onSelectChoice = { _, value -> onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data)) }, - gravity = Gravity.END, + gravity = Gravity.START, ) } @@ -247,15 +224,11 @@ fun PlaybackDialog( onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() -// scope.launch { -// delay(250L) -// settingsFocusRequester.tryRequestFocus() -// } }, onSelectChoice = { _, choice -> onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data)) }, - gravity = Gravity.END, + gravity = Gravity.START, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt deleted file mode 100644 index 2b0247e7..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ /dev/null @@ -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, - 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, - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 334cd062..5e748396 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.playback import android.view.Gravity +import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.activity.compose.BackHandler @@ -28,6 +29,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,12 +37,12 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.layout.ContentScale 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.components.ErrorMessage 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.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.calculateEdgeSize import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle @@ -167,6 +174,7 @@ fun PlaybackPageContent( ), ) val currentSegment by viewModel.currentSegment.collectAsState() + val analyticsState by viewModel.analyticsState.collectAsState() val cues by viewModel.subtitleCues.observeAsState(listOf()) var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } @@ -389,7 +397,7 @@ fun PlaybackPageContent( .fillMaxSize() .background(Color.Transparent), item = currentPlayback?.item, - playerControls = player, + player = player, controllerViewState = controllerViewState, showPlay = playPauseState.showPlay, previousEnabled = true, @@ -412,6 +420,7 @@ fun PlaybackPageContent( }, currentSegment = currentSegment?.segment, showClock = preferences.appPreferences.interfacePreferences.showClock, + analyticsState = analyticsState, ) val subtitleSettings = @@ -427,65 +436,81 @@ fun PlaybackPageContent( remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } // Subtitles - if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) { - val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) - val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } - AndroidView( - factory = { context -> - SubtitleView(context).apply { - subtitleSettings.let { - setStyle(it.toSubtitleStyle()) - setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) - setBottomPaddingFraction(it.margin.toFloat() / 100f) - } - playerState.assHandler?.let { assHandler -> - if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) { - Timber.v("Adding AssSubtitleView") - addView( - AssSubtitleView(context, assHandler).apply { - layoutParams = - FrameLayout - .LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ).apply { gravity = Gravity.CENTER } - }, - ) - } + val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) + val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } + var cueCount by remember { mutableIntStateOf(0) } + + val subtitleVisible = skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface + + AndroidView( + factory = { context -> + SubtitleView(context).apply { + subtitleSettings.let { + setStyle(it.toSubtitleStyle()) + setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) + setBottomPaddingFraction(it.margin.toFloat() / 100f) + } + playerState.assHandler?.let { assHandler -> + if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) { + Timber.v("Adding AssSubtitleView") + addView( + AssSubtitleView(context, assHandler).apply { + layoutParams = + FrameLayout + .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)) - .apply(it) - it.children.firstOrNull { it is AssSubtitleView }?.let { - (it as? AssSubtitleView)?.apply { - val resized = - layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height } - if (resized) { - Timber.v("Resizing AssSubtitleView: $playerSurfaceSize") - layoutParams = - FrameLayout - .LayoutParams( - playerSurfaceSize.width, - playerSurfaceSize.height, - ).apply { gravity = Gravity.CENTER } - } + .apply(subtitleView) + cueCount = cues.size + } + subtitleView.children.firstOrNull { it is AssSubtitleView }?.let { + (it as? AssSubtitleView)?.apply { + val resized = + layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height } + + if (resized && playerSurfaceSize.width > 0 && playerSurfaceSize.height > 0) { + Timber.v("Resizing AssSubtitleView: %s", playerSurfaceSize) + layoutParams = + FrameLayout + .LayoutParams( + playerSurfaceSize.width, + playerSurfaceSize.height, + ).apply { gravity = Gravity.CENTER } } } - }, - onReset = { - it.setCues(null) - }, - modifier = - Modifier - .fillMaxSize(maxSize) - .align(Alignment.TopCenter) - .background(Color.Transparent) - .ifElse(isImageSubtitles, Modifier.alpha(subtitleImageOpacity)), - ) - } + } + }, + onReset = { + it.setCues(null) + }, + modifier = + Modifier + .fillMaxSize(subtitleMaxSize) + .align(Alignment.TopCenter) + .background(Color.Transparent) + .graphicsLayer { + alpha = + if (!subtitleVisible) { + 0f + } else if (isImageSubtitles) { + subtitleImageOpacity + } else { + 1f + } + }, + ) } // Ask to skip intros, etc button @@ -642,7 +667,15 @@ fun PlaybackPageContent( playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null, ), onDismissRequest = { - playbackDialog = null + playbackDialog = + when (type) { + // Go back to settings dialog + PlaybackDialogType.PLAYBACK_SPEED, + PlaybackDialogType.VIDEO_SCALE, + -> PlaybackDialogType.SETTINGS + + else -> null + } if (controllerViewState.controlsVisible) { controllerViewState.pulseControls() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt deleted file mode 100644 index 4bd59347..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt +++ /dev/null @@ -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, - 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, - ) - } - } - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 4dd27e3d..00a9ae44 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -1,6 +1,8 @@ package com.github.damontecres.wholphin.ui.playback import android.content.Context +import android.media.MediaCodecList +import android.os.Build import android.widget.Toast import androidx.annotation.OptIn 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.ItemPlayback 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.preferences.AppPreference 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.StreamChoiceService 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.isNotNullOrBlank 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.MediaSegmentType 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.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaystateCommand @@ -118,6 +123,7 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.Date import java.util.UUID +import kotlin.math.roundToInt import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -175,12 +181,13 @@ class PlaybackViewModel val currentPlayback = MutableStateFlow(null) val currentItemPlayback = MutableLiveData() val currentSegment = MutableStateFlow(null) + val analyticsState = MutableStateFlow(AnalyticsState()) val subtitleCues = MutableLiveData>(listOf()) private lateinit var preferences: UserPreferences internal lateinit var itemId: UUID - internal lateinit var item: BaseItem + internal lateinit var currentItem: PlaylistItem internal var forceTranscoding: Boolean = false private var activityListener: TrackActivityPlaybackListener? = null private val jobs = mutableListOf() @@ -225,8 +232,11 @@ class PlaybackViewModel ) { val softwareDecoding = !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding + val requestBackend = + (destination as? Destination.Playback)?.backend + ?: preferences.appPreferences.playbackPreferences.playerBackend val playerBackend = - when (preferences.appPreferences.playbackPreferences.playerBackend) { + when (requestBackend) { PlayerBackend.UNRECOGNIZED, PlayerBackend.EXO_PLAYER, -> PlayerBackend.EXO_PLAYER @@ -289,21 +299,18 @@ class PlaybackViewModel this.forceTranscoding = (destination as? Destination.Playback)?.forceTranscoding ?: false val positionMs: Long - val itemPlayback: ItemPlayback? val forceTranscoding: Boolean val itemId = when (val d = destination) { is Destination.Playback -> { positionMs = d.positionMs - itemPlayback = d.itemPlayback forceTranscoding = d.forceTranscoding d.itemId } is Destination.PlaybackList -> { positionMs = 0 - itemPlayback = null forceTranscoding = false d.itemId } @@ -314,9 +321,9 @@ class PlaybackViewModel } this.itemId = itemId val queriedItem = api.userLibraryApi.getItem(itemId).content - val base = + val playlistItem = if (queriedItem.type.playable) { - queriedItem + PlaylistItem.Media(BaseItem(queriedItem, false)) } else if (destination is Destination.PlaybackList) { val playlistResult = playlistCreator.createFrom( @@ -344,9 +351,7 @@ class PlaybackViewModel this@PlaybackViewModel.playlist.value = r.playlist } } - r.playlist.items - .first() - .data + r.playlist.items.first() } } } else { @@ -355,12 +360,39 @@ class PlaybackViewModel 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 = play( - item, + firstItem, positionMs, - itemPlayback, forceTranscoding, ) if (!played) { @@ -370,9 +402,13 @@ class PlaybackViewModel if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) { val result = playlistCreator.createFrom(queriedItem) if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { - withContext(Dispatchers.Main) { - this@PlaybackViewModel.playlist.value = result.playlist - } + val currentPlaylist = + 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 * - * @param item the item to play + * @param currentItem the item to play * @param positionMs the starting playback position in milliseconds * @param itemPlayback the parameters for playback such chosen subtitle or audio streams * @param forceTranscoding whether the user has requested to force playback via transcoding */ private suspend fun play( - item: BaseItem, + playlistItem: PlaylistItem, positionMs: Long, - itemPlayback: ItemPlayback? = null, forceTranscoding: Boolean = this.forceTranscoding, ): Boolean = withContext(Dispatchers.IO) { + val item = + when (playlistItem) { + is PlaylistItem.Intro -> playlistItem.item + is PlaylistItem.Media -> playlistItem.item + } + Timber.i("Playing ${item.id}") // New item, so we can clear the media segment tracker & subtitle cues @@ -411,26 +452,25 @@ class PlaybackViewModel ) return@withContext false } - this@PlaybackViewModel.item = item + this@PlaybackViewModel.currentItem = playlistItem this@PlaybackViewModel.itemId = item.id val isLiveTv = item.type == BaseItemKind.TV_CHANNEL val base = item.data // Use the provided playback parameters or else check if the database has some - val playbackConfig = - itemPlayback - ?: serverRepository.currentUser.value?.let { user -> - itemPlaybackDao.getItem(user, base.id)?.let { - Timber.v("Fetched itemPlayback from DB: %s", it) - if (it.sourceId != null) { - it - } else { - null - } + val itemPlayback = + serverRepository.currentUser.value?.let { user -> + itemPlaybackDao.getItem(user, base.id)?.let { + Timber.v("Fetched itemPlayback from DB: %s", it) + if (it.sourceId != null) { + it + } else { + null } } - val mediaSource = streamChoiceService.chooseSource(base, playbackConfig) + } + val mediaSource = streamChoiceService.chooseSource(base, itemPlayback) val plc = streamChoiceService.getPlaybackLanguageChoice(base) if (mediaSource == null) { @@ -486,7 +526,7 @@ class PlaybackViewModel .chooseAudioStream( source = mediaSource, seriesId = base.seriesId, - itemPlayback = playbackConfig, + itemPlayback = itemPlayback, plc = plc, prefs = preferences, ) @@ -498,7 +538,7 @@ class PlaybackViewModel source = mediaSource, audioStream = audioStream, seriesId = base.seriesId, - itemPlayback = playbackConfig, + itemPlayback = itemPlayback, plc = plc, prefs = preferences, )?.index @@ -506,7 +546,7 @@ class PlaybackViewModel Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") val itemPlaybackToUse = - playbackConfig ?: ItemPlayback( + itemPlayback ?: ItemPlayback( rowId = -1, userId = -1, itemId = base.id, @@ -776,6 +816,7 @@ class PlaybackViewModel subtitleIndex, source, ) + Timber.v("onTracksChanged: %s", result) if (result.bothSelected) { player.trackSelectionParameters = result.trackSelectionParameters @@ -885,7 +926,7 @@ class PlaybackViewModel Timber.d("Changing audio track to %s", index) val itemPlayback = itemPlaybackRepository.saveTrackSelection( - item = item, + item = currentItem.item, itemPlayback = currentItemPlayback.value!!, trackIndex = index, type = MediaStreamType.AUDIO, @@ -899,7 +940,7 @@ class PlaybackViewModel streamChoiceService.resolveSubtitleIndex( source = source, audioStreamIndex = index, - seriesId = item.data.seriesId, + seriesId = currentItem.item.data.seriesId, subtitleIndex = itemPlayback.subtitleIndex, prefs = preferences, ) @@ -908,7 +949,7 @@ class PlaybackViewModel } changeStreams( - item, + currentItem.item, itemPlayback, index, resolvedSubtitleIndex, @@ -923,7 +964,7 @@ class PlaybackViewModel Timber.d("Changing subtitle track to %s", index) val itemPlayback = itemPlaybackRepository.saveTrackSelection( - item = item, + item = currentItem.item, itemPlayback = currentItemPlayback.value!!, trackIndex = index, type = MediaStreamType.SUBTITLE, @@ -937,7 +978,7 @@ class PlaybackViewModel streamChoiceService.resolveSubtitleIndex( source = source, audioStreamIndex = itemPlayback.audioIndex, - seriesId = item.data.seriesId, + seriesId = currentItem.item.data.seriesId, subtitleIndex = index, prefs = preferences, ) @@ -946,7 +987,7 @@ class PlaybackViewModel } changeStreams( - item, + currentItem.item, itemPlayback, itemPlayback.audioIndex, resolvedIndex, @@ -981,7 +1022,7 @@ class PlaybackViewModel mediaSourceId: UUID? = currentItemPlayback.value?.sourceId, ): String? = trickPlayInfo?.let { - val itemId = item.id + val itemId = currentItem.id return api.trickplayApi.getTrickplayTileImageUrl( itemId, trickPlayInfo.width, @@ -992,12 +1033,28 @@ class PlaybackViewModel override fun onPlaybackStateChanged(playbackState: Int) { if (playbackState == Player.STATE_ENDED) { - viewModelScope.launchIO { - val nextItem = playlist.value?.peek() - Timber.v("Setting next up to ${nextItem?.id}") - withContext(Dispatchers.Main) { - nextUp.value = nextItem - if (nextItem == null) { + Timber.v("Playback state is STATE_ENDED") + viewModelScope.launchDefault { + when (val nextItem = playlist.value?.peek()) { + is PlaylistItem.Intro -> { + Timber.v("Next item is intro, so playing immediately") + 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() } } @@ -1063,9 +1120,11 @@ class PlaybackViewModel outroShownSegments.add(currentSegment.id) ) { val nextItem = playlist.peek() - Timber.v("Setting next up during outro to ${nextItem?.id}") - withContext(Dispatchers.Main) { - nextUp.value = nextItem + if (nextItem is PlaylistItem.Media) { + Timber.v("Setting next up during outro to ${nextItem?.id}") + withContext(Dispatchers.Main) { + nextUp.value = nextItem.item + } } } else { val behavior = @@ -1190,7 +1249,7 @@ class PlaybackViewModel fun playNextUp() { playlist.value?.let { if (it.hasNext()) { - viewModelScope.launchIO { + viewModelScope.launchDefault { cancelUpNextEpisode() val item = it.getAndAdvance() val played = play(item, 0) @@ -1205,7 +1264,7 @@ class PlaybackViewModel fun playPrevious() { playlist.value?.let { if (it.hasPrevious()) { - viewModelScope.launchIO { + viewModelScope.launchDefault { cancelUpNextEpisode() val item = it.getPreviousAndReverse() val played = play(item, 0) @@ -1266,7 +1325,7 @@ class PlaybackViewModel PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> { Timber.w("Playback error during ${it.playMethod}, falling back to transcoding") changeStreams( - item, + currentItem.item, currentItemPlayback.value!!, currentItemPlayback.value?.audioIndex, 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( eventTime: AnalyticsListener.EventTime, decoderName: String, @@ -1365,7 +1453,7 @@ class PlaybackViewModel initializationDurationMs: Long, ) { Timber.v("onVideoDecoderInitialized: decoder=$decoderName") - currentPlayback.update { it?.copy(videoDecoder = decoderName) } + updateDecoder(decoderName, MediaType.VIDEO) } override fun onVideoDisabled( @@ -1384,7 +1472,7 @@ class PlaybackViewModel decoderReuseEvaluation?.let { decoder -> if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) { 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, ) { Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName") - currentPlayback.update { it?.copy(audioDecoder = decoderName) } + updateDecoder(decoderName, MediaType.AUDIO) } override fun onAudioInputFormatChanged( @@ -1407,7 +1495,7 @@ class PlaybackViewModel decoderReuseEvaluation?.let { decoder -> if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) { 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) { 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( @@ -1478,3 +1597,9 @@ data class MediaSegmentState( val segment: MediaSegmentDto, val interacted: Boolean, ) + +data class AnalyticsState( + val bitrate: String = formatBitrate(0), + val bitrateEstimate: String = formatBitrate(0), + val droppedFrames: Int = 0, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index 27188f57..b2e82eae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.text.intl.Locale import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R 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.ui.launchIO import com.github.damontecres.wholphin.ui.onMain @@ -106,12 +107,13 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( while (maxAttempts > 0 && subtitleCount == newCount) { maxAttempts-- delay(1500) - item = - BaseItem.from( - api.userLibraryApi.getItem(itemId = it.itemId).content, - api, - ) - mediaSource = streamChoiceService.chooseSource(item.data, it) + val base = BaseItem(api.userLibraryApi.getItem(itemId = it.itemId).content) + currentItem = + when (currentItem) { + is PlaylistItem.Intro -> PlaylistItem.Intro(base) + is PlaylistItem.Media -> PlaylistItem.Media(base) + } + mediaSource = streamChoiceService.chooseSource(currentItem.item.data, it) if (mediaSource == null) { // This shouldn't happen, but just in case showToast( @@ -158,7 +160,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( ) } this@downloadAndSwitchSubtitles.changeStreams( - item, + currentItem.item, currentItemPlayback.value!!, audioIndex, newStream.index, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt index b7566482..cfbbe6b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt @@ -108,7 +108,7 @@ object TrackSelectionUtils { true } val audioSelected = - if (audioIndex != null && supportsDirectPlay) { + if (audioIndex != null && audioIndex >= 0 && supportsDirectPlay) { val indexToFind = calculateIndexToFind( audioIndex, @@ -141,7 +141,7 @@ object TrackSelectionUtils { } chosenTrack != null } else { - audioIndex == null + true } return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt new file mode 100644 index 00000000..2270e5f7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt @@ -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, + 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(), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PauseIndicator.kt similarity index 98% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PauseIndicator.kt index 2f812970..8414d5c5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PauseIndicator.kt @@ -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.compose.animation.AnimatedVisibility diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt new file mode 100644 index 00000000..0de48d15 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt similarity index 95% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt index d43b6c27..f423b8ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt @@ -1,6 +1,6 @@ @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 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.TextButton 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.seekForward import com.github.damontecres.wholphin.ui.skipStringRes @@ -123,13 +125,10 @@ sealed interface PlaybackAction { @OptIn(UnstableApi::class) @Composable fun PlaybackControls( - playerControls: Player, + player: Player, controllerViewState: ControllerViewState, onPlaybackActionClick: (PlaybackAction) -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, - moreFocusRequester: FocusRequester, - captionFocusRequester: FocusRequester, - settingsFocusRequester: FocusRequester, onSeekProgress: (Long) -> Unit, showPlay: Boolean, previousEnabled: Boolean, @@ -164,7 +163,7 @@ fun PlaybackControls( verticalArrangement = Arrangement.spacedBy(0.dp), ) { SeekBar( - player = playerControls, + player = player, controllerViewState = controllerViewState, onSeekProgress = onSeekProgress, interactionSource = seekBarInteractionSource, @@ -184,13 +183,12 @@ fun PlaybackControls( .fillMaxWidth(), ) { LeftPlaybackButtons( - moreFocusRequester = moreFocusRequester, onControllerInteraction = onControllerInteraction, onClickPlaybackDialogType = onClickPlaybackDialogType, modifier = Modifier.align(Alignment.CenterStart), ) PlaybackButtons( - player = playerControls, + player = player, initialFocusRequester = initialFocusRequester, onControllerInteraction = onControllerInteraction, onPlaybackActionClick = onPlaybackActionClick, @@ -209,7 +207,7 @@ fun PlaybackControls( TextButton( stringRes = segment.type.skipStringRes, onClick = { - playerControls.seekTo(segment.endTicks.ticks.inWholeMilliseconds) + player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) }, modifier = Modifier @@ -218,8 +216,6 @@ fun PlaybackControls( ) } RightPlaybackButtons( - captionFocusRequester = captionFocusRequester, - settingsFocusRequester = settingsFocusRequester, onControllerInteraction = onControllerInteraction, onClickPlaybackDialogType = onClickPlaybackDialogType, modifier = Modifier, @@ -301,7 +297,6 @@ val buttonSpacing = 12.dp @Composable fun LeftPlaybackButtons( - moreFocusRequester: FocusRequester, onControllerInteraction: () -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, modifier: Modifier = Modifier, @@ -310,24 +305,21 @@ fun LeftPlaybackButtons( modifier = modifier.focusGroup(), horizontalArrangement = Arrangement.spacedBy(buttonSpacing), ) { - // More options PlaybackButton( - iconRes = R.drawable.baseline_more_vert_96, + iconRes = R.drawable.vector_settings, onClick = { onControllerInteraction.invoke() - onClickPlaybackDialogType.invoke(PlaybackDialogType.MORE) + onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS) }, enabled = true, onControllerInteraction = onControllerInteraction, - modifier = Modifier.focusRequester(moreFocusRequester), + modifier = Modifier, ) } } @Composable fun RightPlaybackButtons( - captionFocusRequester: FocusRequester, - settingsFocusRequester: FocusRequester, onControllerInteraction: () -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, modifier: Modifier = Modifier, @@ -345,18 +337,18 @@ fun RightPlaybackButtons( onClickPlaybackDialogType.invoke(PlaybackDialogType.CAPTIONS) }, onControllerInteraction = onControllerInteraction, - modifier = Modifier.focusRequester(captionFocusRequester), + modifier = Modifier, ) - // Playback speed, etc - PlaybackButton( - iconRes = R.drawable.vector_settings, + // Audio + PlaybackFaButton( + enabled = true, + iconRes = R.string.fa_volume_high, onClick = { onControllerInteraction.invoke() - onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS) + onClickPlaybackDialogType.invoke(PlaybackDialogType.AUDIO) }, - enabled = true, onControllerInteraction = onControllerInteraction, - modifier = Modifier.focusRequester(settingsFocusRequester), + modifier = Modifier, ) } } @@ -623,7 +615,7 @@ private fun ButtonPreview() { onControllerInteraction = {}, ) PlaybackFaButton( - iconRes = R.string.fa_shuffle, + iconRes = R.string.fa_volume_high, onClick = {}, onControllerInteraction = {}, textColor = Color.Green, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt new file mode 100644 index 00000000..1420ffb6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt @@ -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(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>, + 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(), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt new file mode 100644 index 00000000..96adc4df --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt @@ -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, + 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, +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt new file mode 100644 index 00000000..e48fbb97 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt @@ -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, + 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, + 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, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt new file mode 100644 index 00000000..a44d1fc7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt @@ -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), + ), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt similarity index 98% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt index 37cb86f7..1ca67657 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt @@ -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 @@ -46,6 +46,8 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp 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 kotlin.time.Duration diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBarState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBarState.kt similarity index 96% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBarState.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBarState.kt index 3de5dca7..16e95d15 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBarState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBarState.kt @@ -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.LaunchedEffect diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekPreviewImage.kt similarity index 98% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekPreviewImage.kt index 4d8ee0e5..35600342 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekPreviewImage.kt @@ -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.compose.foundation.Canvas diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipIndicator.kt similarity index 97% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipIndicator.kt index c2344f5f..3ac39bfa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipIndicator.kt @@ -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.tween diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipSegmentButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipSegmentButton.kt similarity index 98% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipSegmentButton.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipSegmentButton.kt index 4270bf48..f4a14e26 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipSegmentButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipSegmentButton.kt @@ -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.core.InfiniteRepeatableSpec diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index 454a0266..06399fe9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -36,6 +36,7 @@ enum class PreferenceScreenOption { EXO_PLAYER, MPV, SCREENSAVER, + SKIP_SEGMENTS, ; companion object { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 54398c3b..d676c897 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend 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.basicPreferences import com.github.damontecres.wholphin.preferences.screensaverPreferences @@ -142,6 +143,7 @@ fun PreferencesContent( PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences PreferenceScreenOption.MPV -> MpvPreferences PreferenceScreenOption.SCREENSAVER -> screensaverPreferences + PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences } val screenTitle = when (preferenceScreenOption) { @@ -150,6 +152,7 @@ fun PreferencesContent( PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options PreferenceScreenOption.MPV -> R.string.mpv_options PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings + PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior } var visible by remember { mutableStateOf(false) } @@ -707,6 +710,7 @@ fun PreferencesPage( PreferenceScreenOption.EXO_PLAYER, PreferenceScreenOption.MPV, PreferenceScreenOption.SCREENSAVER, + PreferenceScreenOption.SKIP_SEGMENTS, -> { PreferencesContent( initialPreferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt index e0d49631..f9f22591 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme @@ -72,6 +73,16 @@ fun PinEntry( Key.DirectionRight -> "R" Key.DirectionDown -> "D" 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 } onTextChange.invoke(str) @@ -122,6 +133,16 @@ fun PinEntryCreate( Key.DirectionRight -> "R" Key.DirectionDown -> "D" 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 } onTextChange.invoke(str) @@ -154,6 +175,7 @@ fun PinEntryCreate( @Composable fun PinArrowRow(modifier: Modifier = Modifier) { Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { @@ -167,6 +189,12 @@ fun PinArrowRow(modifier: Modifier = Modifier) { fontFamily = FontAwesome, ) } + Text( + text = stringResource(R.string.pin_digits_hint), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index 5fd47621..84e4cea6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -9,19 +9,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.HorizontalDivider 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.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.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Icon -import androidx.tv.material3.IconButtonDefaults -import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface 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.ui.Cards 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 java.util.UUID @@ -62,120 +49,6 @@ sealed interface ServerConnectionStatus { ) : ServerConnectionStatus } -/** - * Display a list of servers plus option to add a new one - */ -@Composable -fun ServerList( - servers: List, - connectionStatus: Map, - onSwitchServer: (JellyfinServer) -> Unit, - onTestServer: (JellyfinServer) -> Unit, - onAddServer: () -> Unit, - onRemoveServer: (JellyfinServer) -> Unit, - allowAdd: Boolean, - allowDelete: Boolean, - modifier: Modifier = Modifier, -) { - var showDeleteDialog by remember { mutableStateOf(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 */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index a4af8580..d975fc29 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -20,8 +20,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -43,11 +43,14 @@ import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.ui.components.BasicDialog 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.components.EditTextBox +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.ifElse @@ -60,16 +63,30 @@ fun SwitchServerContent( modifier: Modifier = Modifier, viewModel: SwitchServerViewModel = hiltViewModel(), ) { - val servers by viewModel.servers.observeAsState(listOf()) - val serverStatus by viewModel.serverStatus.observeAsState(mapOf()) - - var showAddServer by remember { mutableStateOf(false) } - var showDeleteDialog by remember { mutableStateOf(null) } - + val state by viewModel.state.collectAsState() LaunchedEffect(Unit) { viewModel.init() } + when (val st = state.loading) { + is LoadingState.Error -> ErrorMessage(st, modifier) + + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage(modifier) + + LoadingState.Success -> SwitchServerContentInternal(state, viewModel, modifier) + } +} + +@Composable +private fun SwitchServerContentInternal( + state: SwitchServerState, + viewModel: SwitchServerViewModel, + modifier: Modifier = Modifier, +) { + var showAddServer by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(null) } Box( modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null), ) { @@ -108,7 +125,7 @@ fun SwitchServerContent( ) { val focusRequester = remember { FocusRequester() } val firstServerFocus = remember { FocusRequester() } - if (servers.isNotEmpty()) { + if (state.servers.isNotEmpty()) { LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } } LazyRow( @@ -120,16 +137,15 @@ fun SwitchServerContent( .focusRestorer(firstServerFocus) .focusRequester(focusRequester), ) { - itemsIndexed(servers) { index, server -> - val status = serverStatus[server.id] ?: ServerConnectionStatus.Pending + itemsIndexed(state.servers) { index, server -> ServerIconCard( - server = server, - connectionStatus = status, + server = server.server, + connectionStatus = server.status, isCurrentServer = false, // TODO: Determine current server if needed onClick = { - when (status) { + when (server.status) { is ServerConnectionStatus.Success -> { - viewModel.switchServer(server) + viewModel.switchServer(server.server) } ServerConnectionStatus.Pending -> { @@ -137,21 +153,30 @@ fun SwitchServerContent( } is ServerConnectionStatus.Error -> { - viewModel.testServer(server) + viewModel.testServer(server.server) } } }, onLongClick = { - showDeleteDialog = server + showDeleteDialog = server.server }, allowDelete = true, - modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstServerFocus)), + modifier = + Modifier.ifElse( + index == 0, + Modifier.focusRequester(firstServerFocus), + ), ) } // Add Server card - always rightmost item { AddServerCard( onClick = { showAddServer = true }, + modifier = + Modifier.ifElse( + state.servers.isEmpty(), + Modifier.focusRequester(firstServerFocus), + ), ) } } @@ -207,13 +232,11 @@ fun SwitchServerContent( } } - val discoveredServers by viewModel.discoveredServers.observeAsState(listOf()) - // Filter out duplicates within the discovered servers list (same URL appearing multiple times) val filteredDiscoveredServers = - remember(discoveredServers) { + remember(state.discoveredServers) { val seenUrls = mutableSetOf() - discoveredServers.filter { server -> + state.discoveredServers.filter { server -> val normalizedUrl = server.url.lowercase().trim() if (normalizedUrl in seenUrls) { false // Duplicate, filter it out @@ -258,7 +281,7 @@ fun SwitchServerContent( color = MaterialTheme.colorScheme.onSurface, ) - if (filteredDiscoveredServers.isEmpty() && discoveredServers.isEmpty()) { + if (filteredDiscoveredServers.isEmpty() && state.discoveredServers.isEmpty()) { Text( text = stringResource(R.string.searching), style = MaterialTheme.typography.bodyMedium, @@ -294,7 +317,9 @@ fun SwitchServerContent( selected = false, headlineContent = { Text( - text = server.name?.ifBlank { null } ?: server.url, + text = + server.name?.ifBlank { null } + ?: server.url, style = MaterialTheme.typography.bodyLarge, ) }, @@ -323,7 +348,7 @@ fun SwitchServerContent( } } else { // Show enter server address form - val state by viewModel.addServerState.observeAsState(LoadingState.Pending) + val addServerState = state.addServerState var url by remember { mutableStateOf("") } val submit = { viewModel.addServer(url) @@ -357,7 +382,7 @@ fun SwitchServerContent( .focusRequester(textBoxFocusRequester) .fillMaxWidth(), ) - when (val st = state) { + when (val st = addServerState) { is LoadingState.Error -> { Text( text = @@ -371,10 +396,10 @@ fun SwitchServerContent( } TextButton( onClick = { submit.invoke() }, - enabled = url.isNotNullOrBlank() && state == LoadingState.Pending, + enabled = url.isNotNullOrBlank() && addServerState == LoadingState.Pending, modifier = Modifier, ) { - if (state == LoadingState.Loading) { + if (addServerState == LoadingState.Loading) { CircularProgress(Modifier.size(32.dp)) } else { Text(text = stringResource(R.string.submit)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index 08398a86..9c28aff8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.ui.setup import android.content.Context import android.widget.Toast -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R @@ -11,28 +10,23 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager -import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher -import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.HttpClientOptions -import org.jellyfin.sdk.api.client.extensions.quickConnectApi import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.discovery.RecommendedServerInfoScore import org.jellyfin.sdk.discovery.RecommendedServerIssue import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber -import java.util.UUID import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -45,52 +39,54 @@ class SwitchServerViewModel val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, val navigationManager: SetupNavigationManager, - @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, - @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, ) : ViewModel() { - val servers = MutableLiveData>(listOf()) - val serverStatus = MutableLiveData>(mapOf()) - val serverQuickConnect = MutableLiveData>(mapOf()) - - val discoveredServers = MutableLiveData>(listOf()) - - val addServerState = MutableLiveData(LoadingState.Pending) + private val _state = MutableStateFlow(SwitchServerState()) + val state: StateFlow = _state fun clearAddServerState() { - addServerState.value = LoadingState.Pending + _state.update { it.copy(addServerState = LoadingState.Pending) } } fun init() { viewModelScope.launchIO { - withContext(Dispatchers.Main) { - serverStatus.value = mapOf() - serverQuickConnect.value = mapOf() - } + _state.update { SwitchServerState() } val allServers = serverDao .getServers() .map { it.server } .sortedWith(compareBy { it.name }.thenBy { it.url }) - withContext(Dispatchers.Main) { - servers.value = allServers + .map { ServerState(it) } + _state.update { + it.copy( + loading = LoadingState.Success, + servers = allServers, + ) } allServers.forEach { server -> - internalTestServer(server) + internalTestServer(server.server) } } } + private fun updateServerState( + server: JellyfinServer, + result: ServerConnectionStatus, + ) { + _state.update { + val servers = + it.servers.toMutableList().apply { + val index = indexOfFirst { it.server.id == server.id } + set(index, get(index).copy(server = server, status = result)) + } + it.copy(servers = servers) + } + } + fun testServer(server: JellyfinServer) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - ServerConnectionStatus.Pending, - ) - } + updateServerState(server, ServerConnectionStatus.Pending) viewModelScope.launchIO { - delay(1000) + delay(500) val result = internalTestServer(server) if (result is ServerConnectionStatus.Success) { showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) @@ -100,58 +96,34 @@ class SwitchServerViewModel } } - private suspend fun internalTestServer(server: JellyfinServer): ServerConnectionStatus = - try { - val systemInfo = - jellyfin - .createApi( - server.url, - httpClientOptions = - HttpClientOptions( - requestTimeout = 6.seconds, - connectTimeout = 6.seconds, - socketTimeout = 6.seconds, - ), - ).systemApi - .getPublicSystemInfo() - .content - val result = ServerConnectionStatus.Success(systemInfo) - withContext(Dispatchers.Main) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - result, - ) - } + private suspend fun internalTestServer(server: JellyfinServer): ServerConnectionStatus { + val result = + try { + val systemInfo = + jellyfin + .createApi( + server.url, + httpClientOptions = + HttpClientOptions( + requestTimeout = 6.seconds, + connectTimeout = 6.seconds, + socketTimeout = 6.seconds, + ), + ).systemApi + .getPublicSystemInfo() + .content + ServerConnectionStatus.Success(systemInfo) + } catch (ex: Exception) { + Timber.w(ex, "Error checking server ${server.url}") + ServerConnectionStatus.Error(ex.localizedMessage) } - result - } catch (ex: Exception) { - val status = ServerConnectionStatus.Error(ex.localizedMessage) - Timber.w(ex, "Error checking server ${server.url}") - withContext(Dispatchers.Main) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - status, - ) - } - } - status - } + updateServerState(server, result) + return result + } fun switchServer(server: JellyfinServer) { viewModelScope.launchIO { - withContext(Dispatchers.Main) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - ServerConnectionStatus.Pending, - ) - } - } + updateServerState(server, ServerConnectionStatus.Pending) val result = internalTestServer(server) if (result is ServerConnectionStatus.Success) { val updatedServer = @@ -160,9 +132,7 @@ class SwitchServerViewModel version = result.systemInfo.version, ) serverRepository.addAndChangeServer(updatedServer) - withContext(Dispatchers.Main) { - navigationManager.navigateTo(SetupDestination.UserList(updatedServer)) - } + navigationManager.navigateTo(SetupDestination.UserList(updatedServer)) } else if (result is ServerConnectionStatus.Error) { showToast(context, "Error connecting: $${result.message}") } @@ -170,7 +140,7 @@ class SwitchServerViewModel } fun addServer(inputUrl: String) { - addServerState.value = LoadingState.Loading + _state.update { it.copy(addServerState = LoadingState.Loading) } viewModelScope.launchIO { try { val scores = @@ -192,27 +162,10 @@ class SwitchServerViewModel version = serverInfo.version, ) serverRepository.addAndChangeServer(server) - val quickConnect = - jellyfin - .createApi(serverUrl) - .quickConnectApi - .getQuickConnectEnabled() - .content - withContext(Dispatchers.Main) { - serverQuickConnect.value = - serverQuickConnect.value!!.toMutableMap().apply { - put(id, quickConnect) - } - } - withContext(Dispatchers.Main) { - addServerState.value = LoadingState.Success - navigationManager.navigateTo(SetupDestination.UserList(server)) - } + _state.update { it.copy(addServerState = LoadingState.Success) } + navigationManager.navigateTo(SetupDestination.UserList(server)) } else { - withContext(Dispatchers.Main) { - addServerState.value = - LoadingState.Error("Server returned invalid response") - } + _state.update { it.copy(addServerState = LoadingState.Error("Server returned invalid response")) } } } else { Timber.w("Error connecting with %s: %s", inputUrl, scores) @@ -240,14 +193,11 @@ class SwitchServerViewModel "${it.address} - $issues" } val message = "Error, tried addresses:\n$errors" - addServerState.setValueOnMain(LoadingState.Error(message)) + _state.update { it.copy(addServerState = LoadingState.Error(message)) } } } catch (ex: Exception) { Timber.w(ex, "Error creating API for $inputUrl") - withContext(Dispatchers.Main) { - addServerState.value = - LoadingState.Error(exception = ex) - } + _state.update { it.copy(addServerState = LoadingState.Error(exception = ex)) } } } } @@ -262,23 +212,37 @@ class SwitchServerViewModel fun discoverServers() { viewModelScope.launchIO { jellyfin.discovery.discoverLocalServers().collect { server -> - val newServerList = - discoveredServers.value!! - .toMutableList() - .apply { - add( - JellyfinServer( - server.id.toUUID(), - server.name, - server.address, - null, - ), - ) - } - withContext(Dispatchers.Main) { - discoveredServers.value = newServerList + val jellyfinServer = + JellyfinServer( + server.id.toUUID(), + server.name, + server.address, + null, + ) + _state.update { + it.copy( + discoveredServers = + it.discoveredServers + .toMutableList() + .apply { + add(jellyfinServer) + }, + ) } } } } } + +data class SwitchServerState( + val loading: LoadingState = LoadingState.Pending, + val servers: List = emptyList(), + val discoveredServers: List = emptyList(), + val addServerState: LoadingState = LoadingState.Pending, +) + +data class ServerState( + val server: JellyfinServer, + val status: ServerConnectionStatus = ServerConnectionStatus.Pending, + val quickConnect: Boolean = false, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index 53afb662..8b2245ce 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -14,10 +14,12 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,40 +43,45 @@ import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.EditTextBox +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.launch @Composable fun SwitchUserContent( - currentServer: JellyfinServer, + server: JellyfinServer, modifier: Modifier = Modifier, viewModel: SwitchUserViewModel = hiltViewModel( - creationCallback = { it.create(currentServer) }, + creationCallback = { it.create(server) }, ), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() LaunchedEffect(Unit) { viewModel.init() } -// val currentServer by viewModel.serverRepository.currentServer.observeAsState() + val state by viewModel.state.collectAsState() + val currentUser by viewModel.serverRepository.currentUser.observeAsState() - val users by viewModel.users.observeAsState(listOf()) - - val quickConnectEnabled by viewModel.serverQuickConnect.observeAsState(false) - val quickConnect by viewModel.quickConnectState.observeAsState(null) var showAddUser by remember { mutableStateOf(false) } + var username by remember(server) { mutableStateOf("") } - val userState by viewModel.switchUserState.observeAsState(LoadingState.Pending) - val loginAttempts by viewModel.loginAttempts.observeAsState(0) - LaunchedEffect(userState) { + fun showAddUserDialog(user: JellyfinUser?) { + username = user?.name ?: "" + showAddUser = true + } + + LaunchedEffect(state.switchUserState) { if (!showAddUser) { - when (val s = userState) { + when (val s = state.switchUserState) { is LoadingState.Error -> { val msg = s.message ?: s.exception?.localizedMessage Toast.makeText(context, "Error: $msg", Toast.LENGTH_LONG).show() @@ -86,247 +93,281 @@ fun SwitchUserContent( } var switchUserWithPin by remember { mutableStateOf(null) } - currentServer?.let { server -> - Box( - modifier = modifier.dimAndBlur(showAddUser || switchUserWithPin != null), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp), - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.Center) - .padding(16.dp), + when (val st = state.loading) { + is LoadingState.Error -> { + ErrorMessage(st, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + Box( + modifier = modifier.dimAndBlur(showAddUser || switchUserWithPin != null), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.select_user), - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = server.name ?: server.url, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - UserList( - users = users, - currentUser = currentUser, - onSwitchUser = { user -> - if (user.hasPin) { - switchUserWithPin = user - } else { - viewModel.switchUser(user) - } - }, - onAddUser = { showAddUser = true }, - onRemoveUser = { user -> - viewModel.removeUser(user) - }, - onSwitchServer = { - viewModel.setupNavigationManager.navigateTo( - SetupDestination.ServerList, - ) - }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - - if (showAddUser) { - var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) } - LaunchedEffect(Unit) { - viewModel.clearSwitchUserState() - viewModel.resetAttempts() - if (useQuickConnect) { - viewModel.initiateQuickConnect(server) - } - } - BasicDialog( - onDismissRequest = { - viewModel.cancelQuickConnect() - showAddUser = false - }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier - .focusGroup() - .padding(16.dp) - .fillMaxWidth(.4f), + .fillMaxWidth() + .align(Alignment.Center) + .padding(16.dp), ) { - if (useQuickConnect) { - if (quickConnect == null && userState !is LoadingState.Error) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .height(32.dp) - .align(Alignment.CenterHorizontally), - ) { - CircularProgress(Modifier.size(20.dp)) - Text( - text = "Waiting for Quick Connect code...", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.select_user), + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = server.name ?: server.url, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + UserList( + users = state.users, + currentUser = currentUser, + onSwitchUser = { user -> + if (user.accessToken == null) { + showAddUserDialog(user) + } else if (user.hasPin) { + switchUserWithPin = user + } else { + val result = viewModel.trySwitchUser(user) + scope.launch { + result.await()?.let { + Toast.makeText(context, it, Toast.LENGTH_LONG).show() + showAddUserDialog(user) + } + } } - } else if (quickConnect != null) { + }, + onAddUser = { + showAddUserDialog(null) + }, + onRemoveUser = { user -> + viewModel.removeUser(user) + }, + onSwitchServer = { + viewModel.setupNavigationManager.navigateTo( + SetupDestination.ServerList, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + + if (showAddUser) { + var useQuickConnect by remember { mutableStateOf(state.quickConnectEnabled) } + LaunchedEffect(Unit) { + viewModel.clearSwitchUserState() + viewModel.resetAttempts() + if (useQuickConnect) { + viewModel.initiateQuickConnect(server) + } + } + BasicDialog( + onDismissRequest = { + viewModel.cancelQuickConnect() + showAddUser = false + }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .focusGroup() + .padding(16.dp) + .fillMaxWidth(.4f), + ) { + if (useQuickConnect) { + if (state.quickConnectStatus == null && state.switchUserState !is LoadingState.Error) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .height(32.dp) + .align(Alignment.CenterHorizontally), + ) { + CircularProgress(Modifier.size(20.dp)) Text( - text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}", + text = "Waiting for Quick Connect code...", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - Text( - text = quickConnect?.code ?: "Failed to get code", - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier, ) } - UserStateError(userState) - TextButton( - stringRes = R.string.username_or_password, - onClick = { - viewModel.cancelQuickConnect() - viewModel.clearSwitchUserState() - useQuickConnect = false - }, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } else { -// val username = rememberTextFieldState() -// val password = rememberTextFieldState() - var username by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - val onSubmit = { - viewModel.login( - server, - username, - password, - ) - } - val focusRequester = remember { FocusRequester() } - val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } else if (state.quickConnectStatus != null) { Text( - text = "Enter username/password to login to ${server.name ?: server.url}", + text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) - UserStateError(userState) - Row( - verticalAlignment = Alignment.CenterVertically, + Text( + text = state.quickConnectStatus?.code ?: "Failed to get code", + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = "Username", - modifier = Modifier.padding(end = 8.dp), - ) - EditTextBox( - value = username, - onValueChange = { username = it }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - ), - keyboardActions = - KeyboardActions( - onNext = { - passwordFocusRequester.tryRequestFocus() - }, - ), - // onKeyboardAction = { + ) + } + UserStateError(state.switchUserState) + TextButton( + stringRes = R.string.username_or_password, + onClick = { + viewModel.cancelQuickConnect() + viewModel.clearSwitchUserState() + useQuickConnect = false + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } else { + var password by remember { mutableStateOf("") } + val onSubmit = { + viewModel.login( + server, + username, + password, + ) + } + val focusRequester = remember { FocusRequester() } + val passwordFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (username.isBlank()) { + focusRequester.tryRequestFocus() + } else { + passwordFocusRequester.tryRequestFocus() + } + } + Text( + text = "Enter username/password to login to ${server.name ?: server.url}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + UserStateError(state.switchUserState) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.username), + modifier = Modifier.padding(end = 8.dp), + ) + EditTextBox( + value = username, + onValueChange = { username = it }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { + passwordFocusRequester.tryRequestFocus() + }, + ), + // onKeyboardAction = { // passwordFocusRequester.tryRequestFocus() // }, - isInputValid = { userState !is LoadingState.Error }, - modifier = Modifier.focusRequester(focusRequester), - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = "Password", - modifier = Modifier.padding(end = 8.dp), - ) - LaunchedEffect(password) { - viewModel.clearSwitchUserState() - } - EditTextBox( - value = password, - onValueChange = { password = it }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - keyboardActions = - KeyboardActions( - onGo = { onSubmit.invoke() }, - ), - isInputValid = { userState !is LoadingState.Error }, - modifier = Modifier.focusRequester(passwordFocusRequester), - ) - } - TextButton( - stringRes = R.string.login, - onClick = { onSubmit.invoke() }, - enabled = username.isNotNullOrBlank(), - modifier = Modifier.align(Alignment.CenterHorizontally), + isInputValid = { state.switchUserState !is LoadingState.Error }, + modifier = Modifier.focusRequester(focusRequester), ) } - if (loginAttempts > 2) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { Text( - text = "Trouble logging in?", - modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(R.string.password), + modifier = Modifier.padding(end = 8.dp), ) - TextButton( - stringRes = R.string.show_debug_info, - onClick = { - viewModel.navigationManager.navigateTo(Destination.Debug) - }, - modifier = Modifier.align(Alignment.CenterHorizontally), + LaunchedEffect(password) { + viewModel.clearSwitchUserState() + } + EditTextBox( + value = password, + onValueChange = { password = it }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + keyboardActions = + KeyboardActions( + onGo = { onSubmit.invoke() }, + ), + isInputValid = { state.switchUserState !is LoadingState.Error }, + modifier = Modifier.focusRequester(passwordFocusRequester), ) } + TextButton( + stringRes = R.string.login, + onClick = { onSubmit.invoke() }, + enabled = username.isNotNullOrBlank(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + if (state.loginAttempts > 2) { + Text( + text = "Trouble logging in?", + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + TextButton( + stringRes = R.string.show_debug_info, + onClick = { + viewModel.navigationManager.navigateTo(Destination.Debug) + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) } } } - switchUserWithPin?.let { user -> - PinEntryDialog( - onDismissRequest = { switchUserWithPin = null }, - onClickServerAuth = { - showAddUser = true - switchUserWithPin = null - }, - onTextChange = { - if (it == user.pin) viewModel.switchUser(user) - }, - ) - } + } + switchUserWithPin?.let { user -> + PinEntryDialog( + onDismissRequest = { switchUserWithPin = null }, + onClickServerAuth = { + showAddUserDialog(user) + switchUserWithPin = null + }, + onTextChange = { + if (it == user.pin) { + val result = viewModel.trySwitchUser(user) + scope.launch { + result.await()?.let { + Toast.makeText(context, it, Toast.LENGTH_LONG).show() + showAddUserDialog(user) + switchUserWithPin = null + } + } + } + }, + ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index 68b9e583..9a1f06a2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.setup -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.JellyfinServerDao @@ -11,18 +10,22 @@ import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.util.ExceptionHandler 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 kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.HttpClientOptions @@ -30,7 +33,6 @@ import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.quickConnectApi -import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectResult @@ -54,87 +56,85 @@ class SwitchUserViewModel fun create(server: JellyfinServer): SwitchUserViewModel } - val serverQuickConnect = MutableLiveData(false) - - val users = MutableLiveData>(listOf()) - val quickConnectState = MutableLiveData(null) + private val _state = MutableStateFlow(SwitchUserState()) + val state: StateFlow = _state private var quickConnectJob: Job? = null - val switchUserState = MutableLiveData(LoadingState.Pending) - - val loginAttempts = MutableLiveData(0) - fun clearSwitchUserState() { - switchUserState.value = LoadingState.Pending + _state.update { it.copy(switchUserState = LoadingState.Pending) } } fun resetAttempts() { - loginAttempts.value = 0 - } - - init { - init() + _state.update { it.copy(loginAttempts = 0) } } fun init() { - viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) { + viewModelScope.launchDefault { serverRepository.switchServerOrUser() } quickConnectJob?.cancel() viewModelScope.launchIO { - users.setValueOnMain(listOf()) - val serverUsers = getUsers() - withContext(Dispatchers.Main) { - users.setValueOnMain(serverUsers) + _state.update { SwitchUserState() } + try { + val serverUsers = getUsers() + _state.update { + it.copy( + loading = LoadingState.Success, + users = serverUsers, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching users for server $server") + _state.update { + it.copy( + loading = LoadingState.Error(ex), + ) + } } } viewModelScope.launchIO { try { - jellyfin - .createApi( - server.url, - httpClientOptions = - HttpClientOptions( - requestTimeout = 6.seconds, - connectTimeout = 6.seconds, - socketTimeout = 6.seconds, - ), - ).systemApi - .getPublicSystemInfo() val quickConnect by jellyfin - .createApi(server.url) - .quickConnectApi + .createApi( + server.url, + httpClientOptions = + HttpClientOptions( + requestTimeout = 6.seconds, + connectTimeout = 6.seconds, + socketTimeout = 6.seconds, + ), + ).quickConnectApi .getQuickConnectEnabled() - withContext(Dispatchers.Main) { - serverQuickConnect.value = quickConnect - } + _state.update { it.copy(quickConnectEnabled = quickConnect) } + } catch (_: CancellationException) { + // no-op, user may have canceled } catch (ex: Exception) { Timber.w(ex, "Error checking quick connect for server ${server.url}") - withContext(Dispatchers.Main) { - serverQuickConnect.value = false - } + _state.update { it.copy(quickConnectEnabled = false) } } } } - fun switchUser(user: JellyfinUser) { - viewModelScope.launchIO { + fun trySwitchUser(user: JellyfinUser): Deferred = + viewModelScope.async(Dispatchers.IO) { try { val current = serverRepository.changeUser(server, user) - if (current != null) { - withContext(Dispatchers.Main) { - setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) - } + setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) + null + } catch (ex: InvalidStatusException) { + if (ex.status == 401) { + "Credentials expired, please login in again" + } else { + ex.localizedMessage } } catch (ex: Exception) { Timber.e(ex, "Error switching user") - setError("Error switching user", ex) + ex.localizedMessage } } - } fun login( server: JellyfinServer, @@ -150,18 +150,11 @@ class SwitchUserViewModel password = password, ) val current = serverRepository.changeUser(server.url, authenticationResult) - if (current != null) { - withContext(Dispatchers.Main) { - setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) - } - } + setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) } catch (ex: Exception) { Timber.e(ex, "Error logging in user") if (ex is InvalidStatusException && ex.status == 401) { - withContext(Dispatchers.Main) { - switchUserState.value = - LoadingState.Error("Invalid username or password") - } + _state.update { it.copy(switchUserState = LoadingState.Error("Invalid username or password")) } } else { setError("Error during login", ex) } @@ -175,44 +168,37 @@ class SwitchUserViewModel viewModelScope.launchIO { try { val api = jellyfin.createApi(server.url) - var state = + var quickConnectStatus = api .quickConnectApi .initiateQuickConnect() .content + _state.update { it.copy(quickConnectStatus = quickConnectStatus) } - withContext(Dispatchers.Main) { - quickConnectState.value = state - } - - while (!state.authenticated) { + while (!quickConnectStatus.authenticated) { delay(5_000L) - state = + quickConnectStatus = api.quickConnectApi .getQuickConnectState( - secret = state.secret, + secret = quickConnectStatus.secret, ).content - withContext(Dispatchers.Main) { - quickConnectState.value = state - } + _state.update { it.copy(quickConnectStatus = quickConnectStatus) } } val authenticationResult by api.userApi.authenticateWithQuickConnect( - QuickConnectDto(secret = state.secret), + QuickConnectDto(secret = quickConnectStatus.secret), ) val current = serverRepository.changeUser(server.url, authenticationResult) - if (current != null) { - withContext(Dispatchers.Main) { - setupNavigationManager.navigateTo( - SetupDestination.AppContent(current), - ) - } - } + setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) + } catch (_: CancellationException) { + // no-op, user may have canceled } catch (ex: Exception) { Timber.e(ex, "Error during quick connect") if (ex is InvalidStatusException && ex.status == 401) { - withContext(Dispatchers.Main) { - quickConnectState.value = null - serverQuickConnect.value = false + _state.update { + it.copy( + quickConnectEnabled = false, + quickConnectStatus = null, + ) } } setError("Error with Quick Connect", ex) @@ -222,39 +208,86 @@ class SwitchUserViewModel fun cancelQuickConnect() { quickConnectJob?.cancel() - quickConnectState.value = null + _state.update { + it.copy( + quickConnectStatus = null, + ) + } } fun removeUser(user: JellyfinUser) { viewModelScope.launchIO { serverRepository.removeUser(user) val serverUsers = getUsers() - withContext(Dispatchers.Main) { - users.value = serverUsers - } + _state.update { it.copy(users = serverUsers) } } } - private suspend fun getUsers(): List { - val api = jellyfin.createApi(server.url) - return serverDao - .getServer(server.id) - ?.users - ?.sortedBy { it.name } - ?.map { JellyfinUserAndImage(it, api.imageApi.getUserImageUrl(it.id)) } - .orEmpty() - } + private suspend fun getUsers(): List = + withContext(Dispatchers.IO) { + val api = jellyfin.createApi(server.url) + val knownUsers = + serverDao + .getServer(server.id) + ?.users + .orEmpty() + .map { + JellyfinUserAndImage( + user = it, + imageUrl = api.imageApi.getUserImageUrl(it.id), + known = true, + ) + } + val knownUserIds = knownUsers.map { it.user.id } + val publicUsers = + api.userApi + .getPublicUsers() + .content + .map { + JellyfinUser( + id = it.id, + name = it.name, + serverId = server.id, + accessToken = null, + ) + }.filter { it.id !in knownUserIds } + .map { + JellyfinUserAndImage( + user = it, + imageUrl = api.imageApi.getUserImageUrl(it.id), + known = false, + ) + } - private suspend fun setError( + knownUsers + publicUsers + } + + private fun setError( msg: String? = null, ex: Exception? = null, - ) = withContext(Dispatchers.Main) { - loginAttempts.value = (loginAttempts.value ?: 0) + 1 - switchUserState.value = LoadingState.Error(msg, ex) + ) { + _state.update { + it.copy( + loginAttempts = it.loginAttempts + 1, + switchUserState = LoadingState.Error(msg, ex), + ) + } } } data class JellyfinUserAndImage( val user: JellyfinUser, val imageUrl: String?, + val known: Boolean, +) + +data class SwitchUserState( + // LoadingState for fetching available users + val loading: LoadingState = LoadingState.Pending, + val quickConnectEnabled: Boolean = false, + val quickConnectStatus: QuickConnectResult? = null, + val users: List = emptyList(), + // LoadingState for while adding/switching users + val switchUserState: LoadingState = LoadingState.Pending, + val loginAttempts: Int = 0, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt index 1b35cce6..ddc171c9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt @@ -58,6 +58,7 @@ import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import java.util.UUID @@ -75,7 +76,7 @@ fun UserList( onSwitchServer: () -> Unit, modifier: Modifier = Modifier, ) { - var showDeleteDialog by remember { mutableStateOf(null) } + var showDeleteDialog by remember { mutableStateOf(null) } Column( modifier = modifier, @@ -106,7 +107,7 @@ fun UserList( user = user, isCurrentUser = user.user.id == currentUser?.id, onClick = { onSwitchUser.invoke(user.user) }, - onLongClick = { showDeleteDialog = user.user }, + onLongClick = { showDeleteDialog = user }, modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier, ) } @@ -152,28 +153,33 @@ fun UserList( showDeleteDialog?.let { user -> DialogPopup( showDialog = true, - title = user.name ?: user.id.toString(), + title = user.user.name ?: user.user.id.toServerString(), dialogItems = - listOf( - DialogItem( - stringResource(R.string.switch_user), - R.string.fa_arrow_left_arrow_right, - ) { - onSwitchUser.invoke(user) - }, - DialogItem( - stringResource(R.string.delete), - Icons.Default.Delete, - Color.Red.copy(alpha = .8f), - ) { - onRemoveUser.invoke(user) - }, - ), + buildList { + add( + DialogItem( + stringResource(R.string.switch_user), + R.string.fa_arrow_left_arrow_right, + ) { + onSwitchUser.invoke(user.user) + }, + ) + if (user.known) { + add( + DialogItem( + stringResource(R.string.delete), + Icons.Default.Delete, + Color.Red.copy(alpha = .8f), + ) { + onRemoveUser.invoke(user.user) + }, + ) + } + }, onDismissRequest = { showDeleteDialog = null }, dismissOnClick = true, waitToLoad = true, properties = DialogProperties(), - elevation = 5.dp, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt index 9cae3698..70d30254 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt @@ -35,7 +35,7 @@ import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.WatchedIcon import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavItem -import com.github.damontecres.wholphin.ui.playback.PlaybackButton +import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton import com.github.damontecres.wholphin.ui.preferences.SliderPreference import com.github.damontecres.wholphin.ui.preferences.SwitchPreference import kotlinx.coroutines.flow.Flow diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 01335c77..7023ddfa 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -87,6 +87,7 @@ message PlaybackPreferences { bool refresh_rate_switching = 22; bool resolution_switching = 23; string external_player = 24; + bool cinema_mode = 25; } message HomePagePreferences{ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e56a1a43..63f84ae9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -227,7 +227,7 @@ Shorts Trifft nur auf Serien zu - Direktes abspielen von ASS Untertitel + Libass für ASS Untertitel verwenden Direktes abspielen von PGS Untertitel Immer zu Stereo heruntermischen FFmpeg decoder Modul verwenden @@ -579,4 +579,22 @@ Verschiedene Arten Logos anstatt Titelnamen anzeigen Wholphin aktualisiert auf %s + Von \"Weiterschauen\" entfernen + Serie aus \"Als nächstes Anschauen\" entfernen + Mehr anzeigen + TV Serien entdecken + Filme entdecken + Studios in %1$s + Serien anzeigen, die von \"Als nächstes Anschauen\" entfernt wurden + Nie + Zuletzt wiedergegeben + Direkte Wiedergabe mit Libass + Direkte Wiedergabe mit ExoPlayer (eingebaut) + Einbrennen/Auf Server transkodieren + SSA/ASS Untertitel Wiedergabe + Externer Player + Systemstandard + Zeige %1$s mehr + \"Überspringen\" Verhalten + Für Intros, Credits, etc diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index fa312ec6..2dc0c213 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -240,7 +240,7 @@ Buscar actualizaciones Aplica solo a series - Reproducción directa de subtítulos ASS + Usar libass para reproducir subtítulos ASS Reproducción directa de subtítulos PGS Convertir siempre a estéreo Usar módulo decodificador FFmpeg @@ -604,4 +604,16 @@ Eliminar de \"Siguiente\" Ver series eliminadas de \"Siguiente\" Estudios en %1$s + Reproducción directa con libass + Reproducción directa con ExoPlayer + Incrustar/transcodificar en el servidor + Reproducción de subtítulos SSA/ASS + Última reproducción + Nunca + Reproductor externo + Predeterminado del sistema + Muestra %1$s más + Comportamiento al saltar + Para cabecera, créditos finales, etc + Modo cine diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 3d535faf..02f3e0a0 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -251,7 +251,7 @@ Kontrolli uuendusi Kehtib vaid telesarjade puhul Seadistused - Esita ASS-vormingus subtiitreid otse + Kasuta ASS-vormingus subtiitrite jaoks libass teeki Esita PGS-vormingus subtiitreid otse Alati vähenda helikanalite arvu stereoni Kasuta FFmpegi dekooderi moodulit @@ -586,4 +586,16 @@ Leia telesaateid Leia filme Stuudiod: %1$s + Viimati esitatud + Otseesitus libass teegiga + Otseesitus sisseehitatud ExoPlayeri teegiga + Kodeeri serveris + SSA/ASS subtiitrite esitus + Mitte kunagi + Väline meediapleier + Süsteemi vaikeväärtus + Kinovaade + Näita veel: %1$s tk + Käitumine vahelejätmisel + Sissejuhatuste, lõputiitrite jmt jaoks diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 894c286c..56d182c9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -289,16 +289,14 @@ - - - + + + Calendrier du DVR Trié par date de diffusion Appuyez au centre de la croix directionnelle pour mettre en pause/reprendre - Featurettes - - + Bonus Dolby Vision Dolby Atmos @@ -323,7 +321,7 @@ Espacement Format d\'image Afficher les détails - Distribution et équipe + Invités spéciaux Taille des bordures Changement de fréquence de rafraîchissement @@ -545,12 +543,12 @@ Miniature Image avec couleur dynamique Image uniquement - Saisir l\'URL et la clé API + Clé API Se connecter au serveur Seerr Utilisateur Jellyfin Utilisateur local - Rechercher et télécharger des sous-titres + Aucun sous-titre distant n\'a été trouvé En cours Utiliser l\'économiseur d\'écran intégré @@ -600,4 +598,16 @@ Voir plus Parcourir les séries Parcourir les films + Supprimer de continuer à regarder + Supprimer la série de la liste suivante + Voir les séries supprimées de la liste suivante + Studios en %1$s + Jamais + Dernière lecture + Lecture directe avec libass + Lecture directe avec ExoPlayer intégré + Incruster / Transcoder sur le serveur + Lecture des sous-titres SSA/ASS + Lecteur externe + Paramètres système par défaut diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 2e02fb53..f292b891 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -18,7 +18,7 @@ Hiba történt! Nyomja meg a gombot, hogy elküldje a naplófájlokat a szervernek. Megerősít Kritikusi értékelés - %.1f másodperc + %.2f másodperc Alapértelmezett Törlés Rendezte: %1$s diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 3f8fa5ea..90e91ca8 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -10,7 +10,7 @@ Bitrate Lahir Batalkan Perekaman - Batalkan Perekaman Serial + Batalkan Perekaman Seri Batal Chapter Pilih %1$s @@ -41,7 +41,7 @@ Nonaktif Aktif Genre - Buka serial + Buka seri Masuk ke Paksa Sembunyikan kontrol pemutaran @@ -91,7 +91,7 @@ Baru Dirilis Rekomendasi Rekam Program - Rekam Serial + Rekam Seri Hapus dari Favorit Mulai ulang Lanjutkan @@ -203,7 +203,7 @@ Jeda sebelum putar selanjutnya Putar otomatis selanjutnya Periksa Pembaruan - Hanya berlaku untuk Serial TV + Hanya berlaku untuk Seri TV Putar langsung subtitel ASS Putar langsung subtitel PGS @@ -466,7 +466,7 @@ Tampilkan gambar untuk jenis item Wholphin Bawaan Wholphin Ringkas - Gambar Thumbnail Serial + Gambar Thumbnail Seri Gambar Thumbnail Episode Terendah Rendah @@ -560,4 +560,12 @@ Pisahkan jenis Buka artis Buka album + Wholphin diperbarui ke %s + Hapus dari \"Lanjutkan menonton\" + Hapus seri dari \"Berikutnya\" + Lihat seri yang dihapus dari \"Berikutnya\" + Lihat semua + Jelajahi acara TV + Jelajahi film + Studio dari %1$s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 88a3da3c..69051c90 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -59,7 +59,7 @@ Riavvia Riprendi Salva - + Cerca Seleziona Server Seleziona Utente @@ -110,7 +110,7 @@ Valutazione della critica Predefinita Server Scoperti - + Scaricando… Forzata Vai a @@ -233,7 +233,7 @@ Tema Applicazione Cerca automaticamente aggiornamenti Riproduci automaticamente il prossimo episodio - Riproduzione diretta sottotitoli ASS + Usa libass per i sottotitoli ASS Riproduzione diretta sottotitoli PGS Usa il modulo di decodifica FFmpeg Riproduci musica tema @@ -263,7 +263,7 @@ - + Converti sempre in stereo Numero massimo di elementi per riga nella home Scala contenuti predefinita @@ -597,4 +597,23 @@ Tipi separati Preferisci mostrare i loghi per i titoli Wholphin aggiornato a %s + Rimuovi da Continua a guardare + Rimuovi serie da Prossimo + Visualizza serie rimosse da Prossimo + Mostra altro + Scopri serie TV + Scopri film + Studi in %1$s + Riproduzione diretta con libass + Riproduzione diretta con ExoPlayer integrato + Masterizza/trascodifica sul server + Riproduzione sottotitoli SSA/ASS + Mai + Player esterno + Predefinito sistema + Ultima riproduzione + Modalità cinema + Mostra %1$s altri + Comportamento di salto + Per intro, titoli di coda e altro diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 92cefdbe..adb54e50 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -5,7 +5,7 @@ Izlase Pievienot Serveri Pievienot Lietotāju - Audio + Audio celiņš Dzimšanas vieta Bitu ātrums Dzimis @@ -275,4 +275,51 @@ %s minūte %s minūtes + Iestatījumi + Atiestatīt + Atskaņots + Piesātinājums + Tonis + Sarkans + Zaļš + Zils + Albumi + Dziesmas + Augstums + Ekrānsaudzētājs + Ilgums + Gads + Desmitgade + Noņemt + Kolonnas + Atstarpe + Automātiski + Konteiners + Nosaukums + Kodeks + Profils + Līmenis + Izšķirtspēja + Kadru ātrums/skaits + Valoda + Kanāli + AVC + + + SDR + HDR + HDR10 + HDR10+ + HLG + bits + Hz + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Atklāt + Parole + Lietotājvārds diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 647c32c6..32dfd784 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -223,7 +223,7 @@ Volgende in wachtrij automatisch afspelen Controleren op updates Alleen van toepassing op tv-series - + ASS-ondertiteling automatisch tonen PGS-ondertiteling automatisch tonen Geluid altijd omzetten naar stereo @@ -249,14 +249,14 @@ Instellingen Terugspoelen na hervatten Terugspoelen - Instellingen omtrent overslaan van reclame + Reclame Vooruitspoelen - Instellingen omtrent overslaan van intro\'s - Instellingen omtrent overslaan van aftitelingen - Instellingen omtrent overslaan van voorvertoningen - Instellingen omtrent overslaan van samenvattingen + Intro\'s + Afftitelingen + Voorvertoningen + Samenvattingen Update beschikbaar - De url die moet worden gebruikt voor het controleren op updates + URL gebruikt voor het controleren op updates Update-url Tekstgrootte Tekstkleur @@ -531,4 +531,13 @@ Achtergrond tonen Afspelen als? Visualisatie van de huidig afspelende audio vereist toestemming voor opname. + Titels als logo weergeven + Alles selecteren + Afzonderlijke types + Uit Verder kijken verwijderen + Uit Volgende verwijderen + Bekijk meer + Series ontdekken + Films ontdekken + Uit Volgende verwijderd bekijken diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 12154c8a..db921e51 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -208,7 +208,7 @@ Spel neste automatisk Sjekk etter oppdateringar Gjeld berre TV-seriar - Slå saman \'Hald fram med å sjå\' og \'Neste\' + Direkteavspeling av ASS-undertekstar Direkteavspeling av PGS-undertekstar Alltid nedmiks til stereo diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index c096e2ba..0b06bfe1 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -44,10 +44,10 @@ Najlepiej Oceniane Nieobejrzane Zwiastun - Zwiastuny - - - + Zwiastun + Zwiastuny + Zwiastunów + Zwiastunów Potwierdź Data Wydania @@ -88,7 +88,7 @@ Ukryj przyciski odtwarzania Ukryj informacje o debugowaniu Ukryj - Strona Główna + Strona główna Ulubione Usuń Domyślna ścieżka @@ -133,10 +133,10 @@ - Czołówki - - - + Czołówka + Czołówki + Czołówek + Czołówek Utwórz nową playliste Dodaj do playlisty @@ -176,15 +176,15 @@ Kolor czcionki Ustawienia Wyślij raport o błędach - Aktualizacja dostępna + Dostępna aktualizacja MPV: używaj dekodowania sprzętowego Filtr Czcionka kursywy Za kulisami - - - + Za kulisami + Za kulisami + Za kulisami Ustawienia zaawansowane Sprawdź dostępność aktualizacji @@ -200,7 +200,7 @@ Kolekcja Kolekcje Reklama - %.1f sekundy + %.2f sekundy Nieaktywne Wyreżyserowane przez %1$s @@ -215,9 +215,9 @@ %d godzin Typ obrazu - + Domyślne skalowanie - Odtwarzaj bezpośrednio napisy ASS + Używaj libass do napisów ASS Odtwarzaj bezpośrednio napisy PGS Zawsze miksuj w dół do stereo Motyw interfejsu @@ -236,7 +236,7 @@ Opóźnienie przed odtworzeniem kolejnego odcinka Automatycznie odtwarzaj kolejny odcinek Stosowane jedynie wobec seriali - + Maksimum elementów wiersza na stronie domowej Wybierz domyślne elementy do wyświetlenia, pozostałe zostaną ukryte @@ -275,10 +275,10 @@ - Usunięte sceny - - - + Usunięta scena + Usunięte sceny + Usunięte sceny + Usunięte sceny Naciśnij by zmienić stronę Przydatne do debugowania @@ -315,8 +315,8 @@ Błąd serwera Ponów Wideo - Filmy - Oglądanie na żywo + Widea + Oglądaj na żywo Urządzenie uwierzytelniono pomyślnie Hasło Nazwa użytkownika @@ -349,4 +349,285 @@ Wczytaj profil użytkownika z serwera Zapisz do profilu użytkownika na serwerze Wczytaj z klienta webowego + Przekroczono limit czasu odpowiedzi + Nie rozpoznano mowy + Ogólne + Kontener + Tytuł + Kodek + Profil + Poziom + Rozdzielczość + Anamorficzny + Tak + Nie + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Pokaż tytuły + Powtarzaj + Pokaż ulubione kanały na początku + Sortuj kanały według ostatnio oglądanych + AVC + Język + Układ + Kanały + Wygaszacz ekranu + Ustawienia wygaszacza ekranu + Pokaż wygaszacz ekranu + Czas trwania + Zdjęcia + Dołącz typy + Dla jakich typów mediów wyświetlać zdjęcia? + Dopasuj + Przytnij + Wypełnij + Szerokość ramki + Wysokość ramki + Domyślny Wholphin + Kompaktowy Wholphin + Miniatury seriali + Zdjęcie odcinków + Najniższa + Niska + Średnia + Wysoka + Najwyższa + Fioletowy + Pomarańczowy + Niebieski + Czarny + Ignoruj + Pomiń automatycznie + Spytaj o pominięcie + Używaj FFmpeg tylko wtedy, gdy nie istnieje wbudowany dekoder + Używaj FFmpeg zamiast wbudowanych dekoderów + Nigdy nie używaj dekodera FFmpeg + Na końcu odtwarzania + Podczas napisów końcowych + Biały + Jasno szary + Ciemno szary + Żółty + Cyjan + Magenta + Cień + Ramka + Zawijaj + Opakowane + Preferuj MPV + Użyj ExoPlayera do odtwarzania HDR + Plakat (2:3) + 16:9 + 4:3 + Kwadrat (1:1) + Główne + Miniatura + Zdjęcie z dynamicznym kolorem + Tylko zdjęcie + Klucz API + Zaloguj się do Seerr + Użytkownik Jellyfin + Użytkownik lokalny + Ulubione %s + + %s dzień + %s dni + %s dni + %s dni + + + %s minuta + %s minuty + %s minut + %s minut + + Odtwórz zwiastun + Brak zwiastunów + Wyczyść wybór utworów + DTS:X + DTS:HD + DTS:HD MA + TrueHD + DD + DD+ + DTS + Odtwarzanie bezpośrednie Dolby Vision Profile 7 + Ignoruj sprawdzanie zgodności urządzenia + Odkryj + Prośby + Oczekujące + Integracja z Seerr + Usuń serwer Seerr + Serwer Seerr dodany + Szybkie łączenie + Autoryzuj inne urządzenie do logowania się na Twoje konto + Wprowadź kod szybkiego łączenia + Kod musi mieć 6 cyfr + Przezroczystość napisów na obrazie + Rozpoznawanie głosu w toku + Nie wykryto mowy + Nie udało się uruchomić rozpoznawania głosu + Przekroczono limit czasu rozpoznawania głosu + + Piosenki tematyczne + + + + + Przełącz fokus na szufladę nawigacyjną + Nadpisywanie odtwarzania + Pomiń reklamy + Pomiń czołówkę + Pomiń napisy końcowe + Pomiń zapowiedzi + Pomiń streszczenia + Logi szczegółowe + Usunie PIN + Zakres wideo + Typ zakresu wideo + Transfer kolorów + Kolory podstawowe + Format piksela + Ramki referencyjne + NAL + Częstotliwość próbkowania + Programy z kodami kolorystycznymi + Opóźnienie napisów + Styl tła + Zmień rozdzielczość + Lokalny + Jasność + Kontrast + Nasycenie + Odcień + Czerwony + Zielony + Niebieski + Rozmazanie + Zapisz do albumu + Odtwórz pokaz slajdów + Zatrzymaj pokaz slajdów + Na początku + Nie ma więcej zdjęć + Czas trwania pokazu slajdów + Odtwarzaj filmy podczas pokazu slajdów + Mieszanka + Gatunki w %1$s + Ostatnio wydane w %1$s + Rzędy na stronie głównej + Użyj obrazu serialu + Dodaj wiersz do: %1$s + Nadpisać ustawienia na serwerze? + Nadpisać ustawienia lokalne? + Do odcinków + Sugestie dla %1$s + Zwiększ rozmiar wszystkich kart + Zmniejsz rozmiar wszystkich kart + Użyj obrazów miniatur + Ustawienia zapisane + Wyświetl ustawienia wstępne + Wbudowane ustawienia wstępne umożliwiające szybką stylizację wszystkich rzędów + Wybierz rzędy i obrazy na stronie głównej + Zacznij po + Animuj + Maksymalna ocena wiekowa + Bez ograniczeń + Dla wszystkich grup wiekowych + Do %s lat + + + Nie znaleziono napisów na serwerze + Teraz + Użyj wygaszacza ekranu z aplikacji + Czy na pewno chcesz usunąć ten element? + Pokaż opcje zarządzania multimediami + Wyszukaj %s + Aktor + Kompozytor + Scenarzysta + Gwiazda gościnna + Producent + Kierownik + Autor tekstów + Aranżer + Inżynier dźwięku + Mikser + Twórca + Artysta + Artysta albumu + Album + Popularne piosenki + Odtwarzaj dalej + Teledyski + Tekst + Ukryj tekst + Pokaż tekst + Piosenka ma tekst + Usuń z kolejki + Pokaż okładkę albumu + Pokaż wizualizator + Pokaż tło + Odtwórz jako? + Aby móc wizualizować aktualnie odtwarzany dźwięk, wymagane jest pozwolenie na nagrywanie dźwięku. + Kontynuuj + Wybierz wszystkie + Oddzielne typy + Wyświetlanie logo zamiast tytułu + Wholphin zaktualizowany do %s + Usuń z Kontynuuj oglądanie + Usuń serial z Następnie + Zobacz serię usuniętą z Następnie + Zobacz więcej + Odkryj programy TV + Studio w %1$s + Nieznany błąd + Informacje o mediach + Kolejność emisji odcinków + Czas trwania + + Klipy + + + + + + Próbki + + + + + + Filmy krótkometrażowe + + + + + + Krótkie filmy + + + + + Proporcje ekranu + Aktorzy gościnni + Przełączenie częstotliwości odświeżania + Automatycznie + Głębia bitowa + Przestrzeń kolorów + Przeplatany + Liczba klatek + Odkrywaj filmy + Nigdy + Zewnętrzny odtwarzacz + Ostatnio odtwarzane + Domyślny systemowy + Bezpośrednie odtwarzanie z libass + Bezpośrednie odtwarzanie z wbudowanym ExoPlayer + Wypalaj napisy/transkoduj źródło na serwerze + Odtwarzanie napisów SSA/ASS diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 16500357..9da5bf06 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -567,18 +567,18 @@ Mixagem Bastidores - - + + %s minuto %s minutos %s minutos - Insira a URL e a chave de API - Procurar e baixar legendas - Baixar e atualizar - Procurar e baixar - Combinar \"Continue Assistindo\" e \"A Seguir\" - Elenco e equipe + + + + + + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 7e7fa408..371a3107 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -596,4 +596,12 @@ Continuar Separar por tipos Prefiro mostrar logótipos nos títulos + Wholphin foi atualizado para %s + Remover de «Continuar a ver» + Remover série do «Continuar a ver» + Ver séries removidas do «Continuar a ver» + Ver mais + Descobrir Séries + Descobrir Filmes + Estúdios em %1$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c69f25cb..f94fc36b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -417,4 +417,17 @@ Интеграция с Seerr Найти %s Выбрать всё + Задержка субтитров + Воспроизвести трейлер + Трейлеров нет + Длительность + Фотографии + Фиолетовый + Оранжевый + Черный + Пропускать автоматически + Белый + Светло-серый + Темно-серый + Желтый diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml new file mode 100644 index 00000000..d9fcbefc --- /dev/null +++ b/app/src/main/res/values-sl/strings.xml @@ -0,0 +1,224 @@ + + + O aplikaciji + Aktivni posnetki + Dodaj med priljubljene + Dodaj strežnik + Dodaj uporabnika + Zvok + Rojstni kraj + Bitna hitrost + Rojen + Prekliči Snemanje + Prekliči snemanje serije + Prekliči + Poglavja + Izberi %1$s + Počisti predpomnjene slike + Zbirka + Zbirke + Komercialno + Ocena skupnosti + Prišlo je do napake! Pritisnite gumb, da pošljete napako na strežnik. + Potrdi + Nadaljujte z gledanjem + Ocena kritikov + %.2f sekund + Privzeto + Izbriši + Umrl + Režiral %1$s + Režiser + Onemogočeno + Najdeni strežniki + + Prenos poteka… + Omogočeno + Konča se ob %1$s + Vnesite IP ali URL strežnika + Vnesite naslov strežnika + Epizode + Napaka nalaganja kolekcij %1$s + Zunanji + Priljubleni + Velikost + Prisiljeno + Žanre + Odpri serijo + Pojdi na + Skrij kontrole predvajalnika + Skrij debug informacije + Skrij + Domača stran + Takoj + Uvod + #ABCČDEFGHIJKLMNOPQRSŠTUVWXYZŽ + Knjižnica + Informacije o licenci + TV v živo + Nalaganje… + Označi serijo kot predvajano? + Označi serijo kot nepredvajeno? + Označi kot neogledano + Označi kot ogledano + Največji bitrate + Več takih + Več + + Filmi + + Ime + Naslednjo + Ni podatkov + Ni rezultatov + Ni najdenih strežnikov + Ni načrtovanih posnetkov + Ni novih posodobitev + Nobeno + Samo prisiljeni podanpisi + Outro + Pot + + Osebe + + Število predvajanj + Predvajaj od tu + Predvajaj + Pokaži predvajalniške debug informacije + Hitrost predvajanja + Predvajaj + Seznam predvajanja + Seznami predvajanja + Predogled + Nastavitve uporabniškega profila + Čakalna vrsta + Povzetek + Nedavno dodano v %1$s + Nedavno dodano + Nedavno posneto + Nedavno izdano + Priporočeno + Posnemi program + Posnemi serijo + Odstrani iz priljubljenih + Zaženi znova + Nadaljuj + Shrani + + Išči + Iskanje… + Glasovno iskanje + Začetek + Izgovori za iskanje + Procesiranje + Pritisnite nazaj, da prekličete + Napaka pri snemanju zvoka + Napaka pri prepoznavanju glasu + Potrebno je dovoljenje do mikrofona + Napaka omrežja + Ni prepoznanega govora + Prepoznavanje glasu zasedeno + Napaka strežnika + Govor ni zaznan + Neznana napaka + Prepoznavanje glasu ni uspelo zagnati + Časovna omejitev prepoznavanja glasu je potekla + Poskusi znova + Izberite strežnik + Izberite uporabnika + Prikaži informacije o odpravljanju napak + Pokaži naslednjo + Pokaži + Naključno + Preskoči + Datum dodajanja + Datum dodanih epizod + Datum predvajanja + Datum izdaje + Imenu + Naključno + Studiji + Pošlji + Prenos traja dolgo, morda boste morali ponovno zagnati predvajanje + Podnapis + Podnapisi + Predlogi + Zamenjaj strežnik + Preklopi + Najbolje ocenjeno, neogledano + Napovednik + + Napovedniki + + Program snemalnika + Vodnik + Sezona + Sezone + + TV oddaje + + Vmesnik + Neznano + Novosti + Različica + Povečava videa + Video + Videji + Glej v živo + %1$d let staro + Igrajte s transkodiranjem + Informacije o mediji + Prikaži uro + Vrstni red predvajanih epizod + Ustvari nov seznam predvajanja + Dodaj na seznam predvajanja + Zaustavi z enim klikom + Pritisni sredino smerne tipke za zaustavitev/predvajanje + Poševna pisava + Vrsta pisave + Ozadje + Uspeh + Starševska ocena + Trajanje izvajanja + Dodatki + + Ostalo + + + V zakulisju + + + Tematske pesmi + + + Tematski videi + + + Posnetki + + + Izbrisane scene + + + Intervjuji + + + Prizori + + + Vzorci + + + Kratki filmi + + + Kratki filmi + + Dodaj %s med priljubljene + + %s prenos + %s prenosa + %s prenosi + %s prenosov + + diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 42948ac7..c1f2fd7a 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -21,7 +21,7 @@ การให้คะแนนของชุมชน เกิดข้อผิดพลาด! กดปุ่มเพื่อส่งบันทึกไปยังเซิร์ฟเวอร์ของคุณ ตกลง - ดูต่อครับ + ดูต่อ การจัดอันดับนักวิจารณ์ %.2f วินาที ค่าเริ่มต้น @@ -31,10 +31,10 @@ ผู้กำกับ ปิดใช้งาน เซิร์ฟเวอร์ที่ค้นพบ - ดาวน์โหลดและอัปเดต + กำลังดาวน์โหลด… ใช้งาน - จบที่ %1$s + จบเมื่อ %1$s ป้อนที่อยู่ IP หรือ URL ของเซิร์ฟเวอร์ ป้อนที่อยู่เซิร์ฟเวอร์ ตอน @@ -43,7 +43,7 @@ ชื่นชอบ ขนาด บังคับ - ประเภทเพลง + แนวเนื้อหา ไปซีรีย์ ไปที่ ซ่อนการควบคุมการเล่น @@ -53,7 +53,7 @@ ทันที บทนำ #ABCDEFGHIJKLMNOPQRSTUVWXYZ - คลังเพลง + คลัง ข้อมูลใบอนุญาต ทีวี สด กำลังโหลด… @@ -62,13 +62,13 @@ ทำเครื่องหมายว่ายังไม่ได้ดู ทำเครื่องหมายว่าดูแล้ว บิตเรตสูงสุด - เช่นนี้มากขึ้น + เนื้อหาอื่นที่คล้ายกัน เพิ่มเติม ภาพยนตร์ ชื่อ - ถัดไปขึ้นมา + ตอนถัดไป ไม่มีข้อมูล ไม่มีผลลัพธ์ ไม่พบเซิร์ฟเวอร์ @@ -93,7 +93,7 @@ การตั้งค่าโปรไฟล์ผู้ใช้ คิว สรุป - ที่เพิ่งเพิ่ม %1$s + ที่เพิ่งเพิ่มใน %1$s ที่เพิ่งเพิ่ม บันทึกล่าสุด ที่เพิ่งวางขาย @@ -102,9 +102,9 @@ บันทึกซีรีส์ ไม่เป็นที่ชื่นชอบ เริ่มใหม่ - เล่นเกมต่อ + เล่นต่อ บันทึก - ค้นหาและดาวน์โหลด + ค้นหา ค้นหา… ค้นหาด้วยเสียง @@ -128,7 +128,7 @@ เลือกเซิร์ฟเวอร์ เลือกผู้ใช้งาน แสดงข้อมูลการแก้ไขข้อบกพร่อง - แสดงต่อไป + แสดงตอนถัดไป แสดง สุ่ม ข้าม @@ -143,31 +143,31 @@ การดาวน์โหลดใช้เวลานาน คุณอาจต้องเริ่มเล่นใหม่ คำบรรยาย คำบรรยาย - คำแนะนำ + รายการแนะนำ สลับเซิร์ฟเวอร์ สวิตช์ - เรตติ้งสูงสุด (ยังไม่มีคนดู) + รายการที่ได้คะแนนสูงที่ยังไม่ได้ดู ตัวอย่าง - รถพ่วง + ตัวอย่าง - กำหนดการ DVR - แนะนำ - ฤดูกาล - ฤดูกาล + กำหนดการบันทึก + ผังรายการ + ซีซั่น + ซีซั่น รายการทีวี อินเทอร์เฟซ ไม่มีข้อมูล - มีอัพเดต + อัปเดต เวอร์ชัน สเกลวิดีโอ วิดีโอ วิดีโอ ชมสด %1$d ปี - เล่นกับการแปลงรหัส + เล่นโดยใช้การแปลงรหัส ข้อมูลสื่อ แสดงนาฬิกา ลำดับตอนที่ออกอากาศ @@ -221,11 +221,11 @@ UI ขั้นสูง ธีมแอปพลิเคชัน ตรวจสอบการอัปเดตโดยอัตโนมัติ - หน่วงเวลาก่อนเล่นเพลงถัดไป - เล่นอัตโนมัติถัดไป - ตรวจสอบอัปเดต - ใช้ได้กับซีรีส์โทรทัศน์เท่านั้น - รวมตัวเลือก \"ดูต่อ\" และ \"รายการถัดไป\" เข้าด้วยกัน + หน่วงเวลาก่อนเล่นตอนถัดไป + เล่นตอนถัดไปอัตโนมัติ + ตรวจสอบการอัปเดต + ใช้กับรายการโทรทัศน์เท่านั้น + เล่นคำบรรยาย ASS โดยตรง เล่นคำบรรยาย PGS โดยตรง ลดระดับเสียงลงเป็นสเตอริโอเสมอ @@ -274,7 +274,7 @@ การเล่นแบ็กเอนด์ MPV: ใช้การถอดรหัสฮาร์ดแวร์ ปิดใช้งานหากพบปัญหาโปรแกรมหยุดทำงาน - MPV ตัวเลือก + ตัวเลือก MPV ตัวเลือก ExoPlayer ข้ามส่วน ข้ามโฆษณา @@ -307,7 +307,7 @@ อัตราส่วนภาพ แสดงรายละเอียด ประเภทรูปภาพ - นักแสดงและทีมงาน + ดารารับเชิญ ขนาดขอบ การสลับอัตราการรีเฟรช @@ -334,7 +334,7 @@ เฟรมอ้างอิง NAL ภาษา - การจัดวาง + รูปแบบ ช่อง อัตราการสุ่มตัวอย่าง AVC @@ -357,7 +357,7 @@ การสลับความละเอียด ท้องถิ่น เล่นตัวอย่าง - ไม่มีรถพ่วง + ไม่มีตัวอย่างหนัง ล้างตัวเลือกแทร็ก DTS:X DTS:HD @@ -438,7 +438,7 @@ ตั้งค่าทับบนเซิร์ฟเวอร์หรือไม่? แทนที่การตั้งค่าในเครื่องหรือไม่? สำหรับตอนต่างๆ - คำแนะนำสำหรับ %1$s + รายการแนะนำสำหรับ %1$s เพิ่มขนาดสำหรับการ์ดทุกใบ ลดขนาดการ์ดทั้งหมด ใช้ภาพขนาดย่อ @@ -459,22 +459,22 @@ ภาพถ่าย รวมประเภท ควรแสดงภาพสินค้าประเภทใดบ้าง - Fit + พอดี ครอบตัด - สีพื้น - เติมความกว้าง - เติมความสูง + ยืดเต็มจอ + ยืดตามความกว้าง + ยืดตามความสูง Wholphin ค่าเริ่มต้น Wholphin คอมแพค ภาพขนาดย่อของซีรี่ส์ ภาพขนาดย่อของตอน - ต่ำสุด - ต่ำ + เบาสุด + เบา ปานกลาง - สูง - เต็มดาว - สีม่วง - สีส้ม + ดัง + ดังสุด + ม่วง + ส้ม ฟ้าใส ดำ ไม่สนใจ @@ -505,12 +505,12 @@ นิ้วหัวแม่มือ ภาพที่มีสีแบบไดนามิก รูปภาพเท่านั้น - ป้อน URL และ API Key + คีย์ API เข้าสู่ระบบเซิร์ฟเวอร์ Seerr Jellyfin ผู้ใช้ ผู้ใช้ท้องถิ่น - ค้นหาและดาวน์โหลดคำบรรยาย + ไม่พบคำบรรยายระยะไกล ปัจจุบัน ใช้โปรแกรมรักษาหน้าจอในแอป @@ -548,4 +548,7 @@ เลือกทั้งหมด แยกประเภท ควรแสดงโลโก้แทนชื่อเรื่อง + Wolphin อัปเดตเวอร์ชั่นเป็น %s แล้ว + นำออกจากรายการดูต่อ + นำซีรีส์ออกจากรายการถัดไป diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index e1f87f9e..5bb03949 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -13,7 +13,7 @@ Dizi kaydını iptal et İptal Bölümler - Seç: %1$s + %1$s seç Resim önbelleğini temizle Koleksiyon Koleksiyonlar @@ -256,7 +256,7 @@ Güncellemeleri kontrol et Yalnızca diziler için geçerlidir - ASS altyazıları doğrudan oynat + ASS altyazıları için libass kullan PGS altyazıları doğrudan oynat Her zaman Stereo\'ya dönüştür FFmpeg kod çözücü kullan @@ -526,7 +526,7 @@ Yerel kullanıcı Uzak altyazı bulunamadı - Mevcut + Günümüz %s dakika %s dakika @@ -595,4 +595,16 @@ Sıradaki bölümlerden kaldır Sıradaki bölümlerden kaldırılan dizileri göster Stüdyolar: %1$s + Son oynatma + libass ile doğrudan oynatma + Yerleşik ExoPlayer ile doğrudan oynatma + Sunucuda dönüştür ve işle + SSA/ASS altyazı oynatma + Asla + Harici Oynatıcı + Sistem varsayılanı + Atlama Davranışı + İntrolar, jenerikler vb. için + %1$s daha göster + Sinema Modu diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 921f46c8..35eac8d6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -208,7 +208,7 @@ 检查更新 仅适用于电视剧 - 直接播放 ASS 字幕 + 使用 libass 处理 ASS 字幕 直接播放 PGS 字幕 始终降混为立体声 使用 FFmpeg 解码模块 @@ -568,4 +568,15 @@ 从即将播放中移除剧集 查看已从即将播放中移除的剧集 %1$s 制片公司 + 通过 libass 直接播放 + 通过内置的 ExoPlayer 直接播放 + 服务器端烧录/转码 + SSA/ASS 字幕播放 + 从不 + 上次播放 + 外部播放器 + 系统默认 + 显示更多 %1$s 条 + 跳过行为 + 适用于片头、片尾等 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 35d3502b..a79f173a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -101,7 +101,7 @@ 自動檢查更新 自動播放下一集 檢查更新 - 直接播放 ASS 字幕 + 使用 libass 播放 ASS 字幕 直接播放 PGS 字幕 使用 FFmpeg 解碼模組 預設縮放比例 @@ -568,4 +568,15 @@ 從接下來播放中移除劇集 查看從接下來播放中移除的劇集 %1$s 製作公司 + 使用 libass 播放 + 使用 ExoPlayer 內建播放 + 由伺服器燒錄/轉碼 + SSA/ASS 字幕播放方式 + 上次播放 + 不顯示 + 外部播放器 + 系統預設 + 跳過行為 + 用於片頭、片尾等段落 + 顯示更多 (%1$s 筆) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 299efb41..d0b99921 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -390,6 +390,7 @@ Incorrect PIN must be 4 keys or longer Will remove PIN + [0-9] Image disk cache size (MB) View options Columns @@ -769,6 +770,12 @@ Direct play with ExoPlayer built-in Burn in/transcode on server SSA/ASS subtitle playback + Cinema mode + Show %1$s more + Skip behavior + For intros, credits, etc + Play with + Transcoding @string/ass_subtitle_mode_libass @string/ass_subtitle_mode_exoplayer diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt index 25121739..027afb98 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -184,11 +185,15 @@ class BasicUiTests { composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") - composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + composeTestRule + .onNodeWithText("Submit") + .assertIsEnabled() + .requestFocus() + .performClickEnter() TestModule.testDispatcher.scheduler.advanceUntilIdle() - switchServerViewModel.addServerState.value.let { + switchServerViewModel.state.value.addServerState.let { if (it is LoadingState.Error) throw it.exception ?: Exception(it.message) } @@ -249,11 +254,15 @@ class BasicUiTests { composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") - composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + composeTestRule + .onNodeWithText("Submit") + .assertIsEnabled() + .requestFocus() + .performClickEnter() TestModule.testDispatcher.scheduler.advanceUntilIdle() - Assert.assertTrue(switchServerViewModel.addServerState.value is LoadingState.Error) + Assert.assertTrue(switchServerViewModel.state.value.addServerState is LoadingState.Error) composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed() } diff --git a/gradle.properties b/gradle.properties index 23480cc7..26e5ff1a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,4 +30,4 @@ android.dependency.useConstraints=false android.r8.strictFullModeForKeepRules=true android.r8.optimizedResourceShrinking=true android.builtInKotlin=true -android.newDsl=false +android.newDsl=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0235f007..eab5a055 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,18 @@ [versions] -aboutLibraries = "14.0.1" +aboutLibraries = "14.2.0" acra = "5.13.1" -agp = "9.0.1" +agp = "9.1.1" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" hiltNavigationCompose = "1.3.0" hiltWork = "1.3.0" kache = "2.1.1" -kotlin = "2.3.20" -ksp = "2.3.6" +kotlin = "2.3.21" +ksp = "2.3.7" coreKtx = "1.18.0" appcompat = "1.7.1" -composeBom = "2026.03.01" +composeBom = "2026.05.00" mockk = "1.14.9" robolectric = "4.16.1" multiplatformMarkdownRenderer = "0.40.2" @@ -20,17 +20,17 @@ okhttpBom = "5.3.2" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" -tvFoundation = "1.0.0-rc01" -tvMaterial = "1.0.1" +tvFoundation = "1.0.0" +tvMaterial = "1.1.0-alpha01" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" androidx-media3 = "1.10.0" coil = "3.4.0" jellyfin-sdk = "1.7.1" -nav3Core = "1.1.0" +nav3Core = "1.1.1" lifecycleViewmodelNav3 = "2.10.0" material3AdaptiveNav3 = "1.0.0-alpha03" -protobuf = "0.9.6" +protobuf = "0.10.0" datastore = "1.2.1" kotlinx-serialization = "1.11.0" protobuf-javalite = "4.34.1" @@ -41,11 +41,11 @@ tvprovider = "1.1.0" workRuntimeKtx = "2.11.2" paletteKtx = "1.0.0" assMedia = "0.4.0" -kotlinxCoroutinesTest = "1.10.2" +kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" -openapi-generator = "7.21.0" +openapi-generator = "7.22.0" runner = "1.7.0" -wholphin-extensions = "0.1.1" +wholphin-extensions = "0.1.2" [libraries] wholphin-extensions-mpv = { module = "com.github.damontecres.wholphin.mpv:wholphin-mpv", version.ref = "wholphin-extensions" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d997cfc6..b1b8ef56 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c61a118f..b52fb7e7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 739907df..b9bb139f 100755 --- a/gradlew +++ b/gradlew @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/gradlew.bat b/gradlew.bat index e509b2dd..aa5f10b0 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -23,8 +23,8 @@ @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,7 +65,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line @@ -73,21 +73,10 @@ goto fail @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL%