From 36b7390c4ebd5fccb41b4370ac67a92baedf93d7 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:30:56 -0400 Subject: [PATCH 01/92] Fix crash when screensaver networks errors out (#1279) ## Description Fixes a crash if there is a network error when starting the screensaver. Instead, a black screen with an error message shows. The error message fades in/out to different parts of the screen. This applies to both in-app and OS screensavers. ### Related issues Fixes #1247 ### Testing Emulator w/ simulated errors ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/ScreensaverService.kt | 59 +++-- .../wholphin/ui/components/AppScreensaver.kt | 208 +++++++++++++----- 2 files changed, 186 insertions(+), 81 deletions(-) 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/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), + ) + } + } +} From d373b32da5c1d5a3677d49e3e821eeba46a63d94 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:41:24 -0400 Subject: [PATCH 02/92] Fix crash when OS screensaver started before opening Wholphin (#1285) ## Description If the Wholphin OS screensaver starts before Wholphin (the app) is opened, a default image loader is used. Then when Wholphin is opened, it will crash when trying to set the app's image loader. This PR adds configuring the image loader to the screensaver as well. It is safe to do this multiple times. It's only an error to try to replace the default image loader. ### Related issues None ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 10 +-------- .../wholphin/WholphinDreamService.kt | 13 +++++++++++ .../damontecres/wholphin/ui/CoilConfig.kt | 22 +++++++++++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 377dff27..f7182147 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -28,7 +28,6 @@ import androidx.lifecycle.viewModelScope import androidx.navigation3.runtime.NavBackStack import androidx.tv.material3.ExperimentalTvMaterial3Api import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.AppUpgradeHandler @@ -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, 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/ui/CoilConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt index 899498fd..4c40ee2c 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 = false, + enableCache = true, +) + /** * Configure Coil image loading */ From 8f985265584db7bb3902bbd6b9d5e36cc0fdada9 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:41:31 -0400 Subject: [PATCH 03/92] CI & Docs updates (#1284) Increases the heap size for gradle builds in CI Also adds some docs about where/how app settings are stored. No user facing changes --- .github/workflows/main.yml | 2 +- .github/workflows/pr.yml | 2 +- .github/workflows/release.yml | 2 +- DEVELOPMENT.md | 48 ++++++++++++++++++++++++++++------- 4 files changed, 42 insertions(+), 12 deletions(-) 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. From c9cab7a5c2dda3e0c419608c9e9913fc6b8adf2e Mon Sep 17 00:00:00 2001 From: Damontecres <154766448+damontecres@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:34:38 -0400 Subject: [PATCH 04/92] Show season extras row on series overview (#1286) ## Description Adds a row for season level extras on the series overview page below the people rows. The extras will update when switching between season tabs. Like elsewhere in the app, an single extra of a type is show inline and multiple extras of the same type open a grid when clicked. ### Related issues Closes #504 ### Testing Emulator ## Screenshots season_extras Large ## AI or LLM usage None --- .../ui/detail/series/SeriesOverview.kt | 14 +++++++++++-- .../ui/detail/series/SeriesOverviewContent.kt | 21 ++++++++++++++++++- .../ui/detail/series/SeriesViewModel.kt | 9 ++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) 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..5762e55b 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 @@ -98,13 +98,15 @@ 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)) @@ -164,6 +166,7 @@ fun SeriesOverview( EPISODE_ROW -> episodeRowFocusRequester CAST_AND_CREW_ROW -> castCrewRowFocusRequester GUEST_STAR_ROW -> guestStarRowFocusRequester + EXTRAS_ROW -> extrasRowFocusRequester else -> episodeRowFocusRequester }, "series_overview", @@ -280,11 +283,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 -> @@ -364,6 +369,10 @@ fun SeriesOverview( ), ) }, + onClickExtra = { _, extra -> + rowFocused = EXTRAS_ROW + viewModel.navigateTo(extra.destination) + }, canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, deleteOnClick = { showDeleteDialog = it }, modifier = modifier, @@ -435,3 +444,4 @@ fun SeriesOverview( 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..d87d339c 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, @@ -93,6 +96,7 @@ fun SeriesOverviewContent( personOnClick: (Person) -> Unit, canDelete: (BaseItem) -> Boolean, deleteOnClick: (BaseItem) -> Unit, + onClickExtra: (Int, ExtrasItem) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -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..c84f9b8d 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 @@ -184,6 +184,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 +392,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 +406,10 @@ class SeriesViewModel this@SeriesViewModel.episodes.value = episodes } } + viewModelScope.launchIO { + val extras = extrasService.getExtras(seasonId) + this@SeriesViewModel.extras.setValueOnMain(extras) + } } fun setWatched( From 0e37084a168ff9b200854273abf65525d86ecc82 Mon Sep 17 00:00:00 2001 From: Damontecres <154766448+damontecres@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:34:43 -0400 Subject: [PATCH 05/92] Fix always listing next up/continue watching rows first (#1288) ## Description Previously the home page always tried to load the "watching rows" (Next up, Continue watching, or combined) first. This meant that even if the user's customized home page did not put these rows at the top, they would always be listed first anyway. This PR fixes this so rows on the home page are always listed in the right order. ### Related issues Fixes #1275 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/main/HomeViewModel.kt | 106 +++++++++++------- 1 file changed, 67 insertions(+), 39 deletions(-) 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..e9327f44 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,48 +128,71 @@ 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 } + // Include only errors & non-empty successes + if (rowData is HomeRowLoadingState.Error || + (rowData is HomeRowLoadingState.Success && rowData.items.isNotEmpty()) + ) { + _state.update { state -> + val newRows = + state.homeRows.toMutableList().apply { + set(rowIndex, rowData) + } + state.copy( + homeRows = newRows, + ) + } + } else { + Timber.d("Skipping invalid row %s: %s", rowIndex, rowData) + } + } + _state.update { it.copy( loadingState = LoadingState.Success, - homeRows = newRows, + refreshState = LoadingState.Success, + ) + } + } else { + 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, ) } } - 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") From ab5c8840720ce8972cd2ef26f9c50c7de14441c8 Mon Sep 17 00:00:00 2001 From: Damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:08:25 -0400 Subject: [PATCH 06/92] External player follow up fixes (#1294) ## Description Fixes some issues when using an external player: - Send playback started before launching so the last played date is updated - Only use VLC's position if >0 since it returns 0 when playback completes ### Related issues Follow up to #1256 ### Testing Emulator & nvidia shield ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 24 ++++- .../services/PlaybackLifecycleObserver.kt | 8 -- .../wholphin/ui/playback/PlayExternalPage.kt | 94 +++++++++++-------- 3 files changed, 76 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index f7182147..1fa290d0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock @@ -82,6 +83,7 @@ import javax.inject.Inject @AndroidEntryPoint class MainActivity : AppCompatActivity() { private val viewModel: MainActivityViewModel by viewModels() + private val playExternalViewModel: PlayExternalViewModel by viewModels() @Inject lateinit var userPreferencesDataStore: DataStore @@ -152,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) } } @@ -271,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() { @@ -471,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/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/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index c14d184d..e3592372 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 @@ -33,6 +32,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 +40,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 +51,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 +77,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 @@ -252,7 +251,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 +287,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 +304,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 +335,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 +376,7 @@ class PlayExternalViewModel } data class PlayExternalState( - val loading: LoadingState = LoadingState.Loading, + val loading: LoadingState = LoadingState.Pending, val intent: Intent = Intent(), ) @@ -369,8 +386,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 +397,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 +423,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) { From 8dd396b777118f19b2a1328529c17cf8fa948cd3 Mon Sep 17 00:00:00 2001 From: Damontecres <154766448+damontecres@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:50:23 -0400 Subject: [PATCH 07/92] Move skip segments settings to their own page (#1306) ## Description Moves the five skip segment settings to their own page. So there's now a single "Skip behavior" that you click to bring up the settings for the five. I doubt many people change these frequently, so moving them to a new page de-clutters the settings page. ### Related issues N/A ### Testing Emulator ## Screenshots ### Advanced settings single_setting Large ### Skip segment specific settings skip_settings Large ## AI or LLM usage None --- .../wholphin/preferences/AppPreference.kt | 60 +++++++------------ .../ui/preferences/PreferenceUtils.kt | 1 + .../ui/preferences/PreferencesContent.kt | 4 ++ app/src/main/res/values/strings.xml | 2 + 4 files changed, 30 insertions(+), 37 deletions(-) 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 9ca6df06..3cac262a 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 @@ -114,30 +114,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, @@ -630,6 +606,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, @@ -1142,6 +1125,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( @@ -1169,6 +1167,7 @@ val advancedPreferences = listOf( AppPreference.OneClickPause, AppPreference.GlobalContentScale, + AppPreference.SkipSegments, AppPreference.MaxBitrate, AppPreference.RefreshRateSwitching, AppPreference.ResolutionSwitching, @@ -1176,19 +1175,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/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 a4234f17..52d182e1 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8857efff..92d82972 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -767,6 +767,8 @@ Direct play with ExoPlayer built-in Burn in/transcode on server SSA/ASS subtitle playback + Skip behavior + For intros, credits, etc @string/ass_subtitle_mode_libass @string/ass_subtitle_mode_exoplayer From cc295cff386627cb306851c48a0af7285ddd803e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:50:56 -0400 Subject: [PATCH 08/92] Update dependency com.google.devtools.ksp to v2.3.7 (#1296) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0235f007..67d0169c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ hiltNavigationCompose = "1.3.0" hiltWork = "1.3.0" kache = "2.1.1" kotlin = "2.3.20" -ksp = "2.3.6" +ksp = "2.3.7" coreKtx = "1.18.0" appcompat = "1.7.1" composeBom = "2026.03.01" From 0f9d458bc7782b095b2ee9050936abf3128e63b1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:51:09 -0400 Subject: [PATCH 09/92] Update Dependencies (#1293) --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 67d0169c..3712c45a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ kotlin = "2.3.20" ksp = "2.3.7" coreKtx = "1.18.0" appcompat = "1.7.1" -composeBom = "2026.03.01" +composeBom = "2026.04.01" mockk = "1.14.9" robolectric = "4.16.1" multiplatformMarkdownRenderer = "0.40.2" @@ -27,7 +27,7 @@ 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" From 50174db22e922c4cd3a2a68cb7833943f24e6328 Mon Sep 17 00:00:00 2001 From: Damontecres <154766448+damontecres@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:51:43 -0400 Subject: [PATCH 10/92] Updates to the playback debug overlay (#1290) ## Description Lots of minor changes to the debug overlay - Adjusts the size and layout - Smaller font size - Only shows selected tracks by default - Expanding tracks are scrollable and don't cover controls - Show (estimated) network bitrate - Show heap memory usage - Better controls/chapters/queue animations - Minor optimizations Dev note: There's lots of code reorganization too ### Related issues Related to #1216 Related to https://github.com/damontecres/Wholphin/issues/528 ### Testing Emulator & nvidia shield ## Screenshots ### Selected tracks overlay1 Large ### Expanded tracks, scrolled down overlay2 Large ### Transcode info overlay3 Large ## AI or LLM usage None --- .../wholphin/ui/components/HiddenFocusBox.kt | 33 + .../ui/detail/collection/CollectionDetails.kt | 14 +- .../ui/detail/music/NowPlayingButtons.kt | 13 +- .../ui/detail/music/NowPlayingOverlay.kt | 2 +- .../ui/detail/music/NowPlayingPage.kt | 4 +- .../ui/playback/PlaybackDebugOverlay.kt | 144 ---- .../wholphin/ui/playback/PlaybackDialog.kt | 7 +- .../wholphin/ui/playback/PlaybackOverlay.kt | 705 ------------------ .../wholphin/ui/playback/PlaybackPage.kt | 10 +- .../wholphin/ui/playback/PlaybackTrackInfo.kt | 122 --- .../wholphin/ui/playback/PlaybackViewModel.kt | 81 +- .../ui/playback/overlay/ChapterRowOverlay.kt | 157 ++++ .../playback/{ => overlay}/PauseIndicator.kt | 2 +- .../ui/playback/overlay/PlaybackController.kt | 256 +++++++ .../{ => overlay}/PlaybackControls.kt | 12 +- .../playback/overlay/PlaybackDebugOverlay.kt | 298 ++++++++ .../ui/playback/overlay/PlaybackOverlay.kt | 399 ++++++++++ .../ui/playback/overlay/PlaybackTrackInfo.kt | 229 ++++++ .../ui/playback/overlay/QueueRowOverlay.kt | 99 +++ .../ui/playback/{ => overlay}/SeekBar.kt | 4 +- .../ui/playback/{ => overlay}/SeekBarState.kt | 2 +- .../{ => overlay}/SeekPreviewImage.kt | 2 +- .../playback/{ => overlay}/SkipIndicator.kt | 2 +- .../{ => overlay}/SkipSegmentButton.kt | 2 +- .../wholphin/ui/theme/ThemePreview.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 26 files changed, 1589 insertions(+), 1013 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/PauseIndicator.kt (98%) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/PlaybackControls.kt (98%) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/SeekBar.kt (98%) rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/SeekBarState.kt (96%) rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/SeekPreviewImage.kt (98%) rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/SkipIndicator.kt (97%) rename app/src/main/java/com/github/damontecres/wholphin/ui/playback/{ => overlay}/SkipSegmentButton.kt (98%) 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/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt index 7e0ca325..2f61fa4f 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 @@ -7,7 +7,6 @@ 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 @@ -50,6 +49,7 @@ 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.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 @@ -363,15 +363,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, 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..d7da35ed 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 @@ -65,10 +65,10 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.LoadingPage 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 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..8217a516 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,6 +36,9 @@ 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 @@ -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( 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..68dcf9c5 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 @@ -76,6 +76,12 @@ 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 +173,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 +396,7 @@ fun PlaybackPageContent( .fillMaxSize() .background(Color.Transparent), item = currentPlayback?.item, - playerControls = player, + player = player, controllerViewState = controllerViewState, showPlay = playPauseState.showPlay, previousEnabled = true, @@ -412,6 +419,7 @@ fun PlaybackPageContent( }, currentSegment = currentSegment?.segment, showClock = preferences.appPreferences.interfacePreferences.showClock, + analyticsState = analyticsState, ) val subtitleSettings = 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 9bb0e999..551fdd64 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 @@ -51,6 +53,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.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -104,6 +107,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 @@ -117,6 +121,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 @@ -174,6 +179,7 @@ class PlaybackViewModel val currentPlayback = MutableStateFlow(null) val currentItemPlayback = MutableLiveData() val currentSegment = MutableStateFlow(null) + val analyticsState = MutableStateFlow(AnalyticsState()) val subtitleCues = MutableLiveData>(listOf()) @@ -353,7 +359,6 @@ class PlaybackViewModel } viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } - val item = BaseItem.from(base, api) val played = play( @@ -1343,6 +1348,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, @@ -1350,7 +1384,7 @@ class PlaybackViewModel initializationDurationMs: Long, ) { Timber.v("onVideoDecoderInitialized: decoder=$decoderName") - currentPlayback.update { it?.copy(videoDecoder = decoderName) } + updateDecoder(decoderName, MediaType.VIDEO) } override fun onVideoDisabled( @@ -1369,7 +1403,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) } } } @@ -1381,7 +1415,7 @@ class PlaybackViewModel initializationDurationMs: Long, ) { Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName") - currentPlayback.update { it?.copy(audioDecoder = decoderName) } + updateDecoder(decoderName, MediaType.AUDIO) } override fun onAudioInputFormatChanged( @@ -1392,7 +1426,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) } } } @@ -1451,6 +1485,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( @@ -1463,3 +1528,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/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..04f3d80a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt @@ -0,0 +1,256 @@ +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.FocusRequester +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), + ) + } + } + // TODO need to move these up a level? + val moreFocusRequester = remember { FocusRequester() } + val captionFocusRequester = remember { FocusRequester() } + val settingsFocusRequester = remember { FocusRequester() } + 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, + moreFocusRequester = moreFocusRequester, + captionFocusRequester = captionFocusRequester, + settingsFocusRequester = settingsFocusRequester, + ) + } +} 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 98% 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..ae037e38 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,7 +125,7 @@ sealed interface PlaybackAction { @OptIn(UnstableApi::class) @Composable fun PlaybackControls( - playerControls: Player, + player: Player, controllerViewState: ControllerViewState, onPlaybackActionClick: (PlaybackAction) -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, @@ -164,7 +166,7 @@ fun PlaybackControls( verticalArrangement = Arrangement.spacedBy(0.dp), ) { SeekBar( - player = playerControls, + player = player, controllerViewState = controllerViewState, onSeekProgress = onSeekProgress, interactionSource = seekBarInteractionSource, @@ -190,7 +192,7 @@ fun PlaybackControls( modifier = Modifier.align(Alignment.CenterStart), ) PlaybackButtons( - player = playerControls, + player = player, initialFocusRequester = initialFocusRequester, onControllerInteraction = onControllerInteraction, onPlaybackActionClick = onPlaybackActionClick, @@ -209,7 +211,7 @@ fun PlaybackControls( TextButton( stringRes = segment.type.skipStringRes, onClick = { - playerControls.seekTo(segment.endTicks.ticks.inWholeMilliseconds) + player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) }, modifier = Modifier 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..47ee1c85 --- /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, + onClick = { + onClickPlaylist.invoke(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/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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 92d82972..fa9364cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -767,6 +767,7 @@ Direct play with ExoPlayer built-in Burn in/transcode on server SSA/ASS subtitle playback + Show %1$s more Skip behavior For intros, credits, etc From 3f0613f5f4dfa9c6a5b7a96bec20ec0225cbbb8c Mon Sep 17 00:00:00 2001 From: Damontecres <154766448+damontecres@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:35:12 -0400 Subject: [PATCH 11/92] Fix play/shuffle buttons on music library tabs (#1307) ## Description Fixes the play & shuffle buttons on music library tabs. They previously attempted to play the audio via the video playback method. This PR adds the ability to override the buttons and does so for music libraries. ### Related issues Fixes #1304 ### Testing Emulator, compared results against the web UI ## Screenshots N/A ## AI or LLM usage None --- .../ui/components/CollectionFolderGrid.kt | 154 ++++++++++++------ .../ui/detail/CollectionFolderMusic.kt | 105 +++++++++--- .../ui/detail/CollectionFolderPhotoAlbum.kt | 43 +++-- .../wholphin/ui/detail/FavoritesPage.kt | 20 ++- 4 files changed, 233 insertions(+), 89 deletions(-) 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..848288d5 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 @@ -570,7 +570,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 +627,7 @@ fun CollectionFolderGrid( itemId: String, initialFilter: CollectionFolderFilter, recursive: Boolean, - onClickItem: (Int, BaseItem) -> Unit, + actions: GridClickActions, sortOptions: List, playEnabled: Boolean, defaultViewOptions: ViewOptions, @@ -630,6 +667,61 @@ fun CollectionFolderGrid( var showDeleteDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val gridActions = + remember(actions) { + GridClickActions( + onClickItem = actions.onClickItem, + onLongClickItem = + actions.onLongClickItem ?: { position, item -> + moreDialog.makePresent(PositionItem(position, item)) + }, + 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 +754,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 +779,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( @@ -782,7 +833,7 @@ fun CollectionFolderGrid( showDeleteDialog = PositionItem(position, item) }, onClickGoTo = { - onClickItem.invoke(position, it) + gridActions.onClickItem.invoke(position, it) }, onClickAddToQueue = { viewModel.addToQueue(it, 0) @@ -1165,3 +1216,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/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/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( From ea6a0227b090947b07a937ffc47073dc8c981e39 Mon Sep 17 00:00:00 2001 From: Damontecres <154766448+damontecres@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:44:01 -0400 Subject: [PATCH 12/92] Update issue template --- .github/ISSUE_TEMPLATE/01-playback.yml | 1 + 1 file changed, 1 insertion(+) 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 From edb37f5d69718f697f11b1b1acf4de48f2520c98 Mon Sep 17 00:00:00 2001 From: Damontecres <154766448+damontecres@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:16:26 -0400 Subject: [PATCH 13/92] Add support for cinema mode (#1305) ## Description Adds support for cinema mode, which is enabled by default. When enabled and if starting playback from the beginning, the server is queried for any "intros", ie prerolls or trailers, to play first. These are shown in the queue and can be skipped if desired. The server has to provide the intros, so a plugin like https://github.com/jellyfin/jellyfin-plugin-intros or https://github.com/CherryFloors/jellyfin-plugin-cinemamode is required server-side. ### Related issues Closes #418 Closes #1180 ### Testing Emulator and server w/ https://github.com/CherryFloors/jellyfin-plugin-cinemamode ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/Playlist.kt | 31 ++++- .../wholphin/preferences/AppPreference.kt | 13 ++ .../preferences/AppPreferencesSerializer.kt | 1 + .../wholphin/services/AppUpgradeHandler.kt | 7 + .../wholphin/services/PlaylistCreator.kt | 13 +- .../wholphin/ui/playback/PlayExternalPage.kt | 25 ++-- .../wholphin/ui/playback/PlaybackViewModel.kt | 122 +++++++++++++----- .../ui/playback/SubtitleSearchUtils.kt | 16 ++- .../ui/playback/overlay/QueueRowOverlay.kt | 4 +- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 1 + 11 files changed, 170 insertions(+), 64 deletions(-) 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 3cac262a..ecaa0fac 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 @@ -455,6 +455,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, @@ -1166,6 +1178,7 @@ val advancedPreferences = preferences = listOf( AppPreference.OneClickPause, + AppPreference.CinemaMode, AppPreference.GlobalContentScale, AppPreference.SkipSegments, AppPreference.MaxBitrate, 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/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/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index e3592372..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 @@ -24,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 @@ -105,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( @@ -134,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 @@ -154,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, 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 551fdd64..680750c3 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 @@ -35,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 @@ -185,7 +186,7 @@ class PlaybackViewModel 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() @@ -319,9 +320,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( @@ -349,9 +350,7 @@ class PlaybackViewModel this@PlaybackViewModel.playlist.value = r.playlist } } - r.playlist.items - .first() - .data + r.playlist.items.first() } } } else { @@ -359,10 +358,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, @@ -374,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) } } } @@ -384,18 +416,24 @@ 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 @@ -415,7 +453,7 @@ 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 @@ -875,7 +913,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, @@ -889,7 +927,7 @@ class PlaybackViewModel streamChoiceService.resolveSubtitleIndex( source = source, audioStreamIndex = index, - seriesId = item.data.seriesId, + seriesId = currentItem.item.data.seriesId, subtitleIndex = itemPlayback.subtitleIndex, prefs = preferences, ) @@ -898,7 +936,7 @@ class PlaybackViewModel } changeStreams( - item, + currentItem.item, itemPlayback, index, resolvedSubtitleIndex, @@ -913,7 +951,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, @@ -927,7 +965,7 @@ class PlaybackViewModel streamChoiceService.resolveSubtitleIndex( source = source, audioStreamIndex = itemPlayback.audioIndex, - seriesId = item.data.seriesId, + seriesId = currentItem.item.data.seriesId, subtitleIndex = index, prefs = preferences, ) @@ -936,7 +974,7 @@ class PlaybackViewModel } changeStreams( - item, + currentItem.item, itemPlayback, itemPlayback.audioIndex, resolvedIndex, @@ -971,7 +1009,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, @@ -982,12 +1020,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() } } @@ -1053,9 +1107,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 = @@ -1180,7 +1236,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) @@ -1195,7 +1251,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) @@ -1256,7 +1312,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, 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/overlay/QueueRowOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt index 47ee1c85..a44d1fc7 100644 --- 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 @@ -79,9 +79,9 @@ fun QueueRowOverlay( if (isFocused) controllerViewState.pulseControls() } SeasonCard( - item = item, + item = item.item, onClick = { - onClickPlaylist.invoke(item) + onClickPlaylist.invoke(item.item) controllerViewState.hideControls() }, onLongClick = {}, diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 7b7b543b..69df8689 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -86,6 +86,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/strings.xml b/app/src/main/res/values/strings.xml index fa9364cb..63a83aae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -767,6 +767,7 @@ 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 From 8cf99900e5eaa8f7422badbbec3140599939edcf Mon Sep 17 00:00:00 2001 From: Damontecres <154766448+damontecres@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:09:19 -0400 Subject: [PATCH 14/92] Fix index error on home page refresh with empty rows (#1318) ## Description Fixes an bug caused when the home page has an empty row before the last row and there are non-empty rows later, an `IndexOutOfBoundsException` would be thrown. Also removes the loading indicator if an unknown error occurs during a refresh. ### Related issues Fixes #1314 ### Testing Emulator & shield ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/main/HomeViewModel.kt | 33 ++++++------------- 1 file changed, 10 insertions(+), 23 deletions(-) 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 e9327f44..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 @@ -151,21 +151,14 @@ class HomeViewModel } Timber.v("Got row data index=%s", rowIndex) remaining.removeIf { it.index == rowIndex } - // Include only errors & non-empty successes - if (rowData is HomeRowLoadingState.Error || - (rowData is HomeRowLoadingState.Success && rowData.items.isNotEmpty()) - ) { - _state.update { state -> - val newRows = - state.homeRows.toMutableList().apply { - set(rowIndex, rowData) - } - state.copy( - homeRows = newRows, - ) - } - } else { - Timber.d("Skipping invalid row %s: %s", rowIndex, rowData) + _state.update { state -> + val newRows = + state.homeRows.toMutableList().apply { + set(rowIndex, rowData) + } + state.copy( + homeRows = newRows, + ) } } _state.update { @@ -175,14 +168,7 @@ class HomeViewModel ) } } else { - val rows = - deferred - .awaitAll() - .filter { - // Include only errors & non-empty successes - it is HomeRowLoadingState.Error || - (it is HomeRowLoadingState.Success && it.items.isNotEmpty()) - } + val rows = deferred.awaitAll() Timber.v("Got all rows") _state.update { it.copy( @@ -198,6 +184,7 @@ class HomeViewModel 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)) From 5571654a2250b10cbe43319fad9126d633aacd2d Mon Sep 17 00:00:00 2001 From: Damontecres <154766448+damontecres@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:52:57 -0400 Subject: [PATCH 15/92] Show overlay for extra items (#1320) ## Description Shows the overlay (watched, progress bar) for extras appearing as single items. Grouped extras individually show the overlay when clicking into. If all of the grouped are played, the group is show with the watched indicator. Also very slightly optimizations the UI by offloading the title/subtitle/imageUrl to object creation time instead of when they're shown on screen. ### Related issues Closes #1311 ### Testing Shield testing with different extras ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/data/ExtrasItem.kt | 21 +++++++-- .../wholphin/services/ExtrasService.kt | 47 ++++++++++++++++++- .../wholphin/ui/cards/ExtrasRow.kt | 43 +++-------------- 3 files changed, 69 insertions(+), 42 deletions(-) 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/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/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 ) From f57954cf4f76678213f7802255eec840cb6fd43b Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Sun, 12 Apr 2026 07:30:35 +0000 Subject: [PATCH 16/92] Translated using Weblate (German) Currently translated at 100.0% (538 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e56a1a43..9be68c5a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -579,4 +579,11 @@ 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 From 1d55073ccd8d3e2f08392e1fa05bf25098ea4c5f Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 13 Apr 2026 07:35:37 +0000 Subject: [PATCH 17/92] Translated using Weblate (French) Currently translated at 100.0% (538 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 894c286c..cd74bb60 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -600,4 +600,8 @@ 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 From ac98dd4a2ba8dd0627548f5d0238b339bd268aed Mon Sep 17 00:00:00 2001 From: true-seeker Date: Sun, 12 Apr 2026 13:12:07 +0000 Subject: [PATCH 18/92] Translated using Weblate (Russian) Currently translated at 62.8% (338 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ru/ --- app/src/main/res/values-ru/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 Выбрать всё + Задержка субтитров + Воспроизвести трейлер + Трейлеров нет + Длительность + Фотографии + Фиолетовый + Оранжевый + Черный + Пропускать автоматически + Белый + Светло-серый + Темно-серый + Желтый From 10a251928540581de3ce88796a4b11f9f831208c Mon Sep 17 00:00:00 2001 From: TheMatrixan Date: Mon, 13 Apr 2026 06:54:32 +0000 Subject: [PATCH 19/92] Translated using Weblate (Polish) Currently translated at 100.0% (538 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 244 ++++++++++++++++++++++++- 1 file changed, 240 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index c096e2ba..a4162b9f 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -88,7 +88,7 @@ Ukryj przyciski odtwarzania Ukryj informacje o debugowaniu Ukryj - Strona Główna + Strona główna Ulubione Usuń Domyślna ścieżka @@ -176,7 +176,7 @@ Kolor czcionki Ustawienia Wyślij raport o błędach - Aktualizacja dostępna + Dostępna aktualizacja MPV: używaj dekodowania sprzętowego Filtr Czcionka kursywy @@ -200,7 +200,7 @@ Kolekcja Kolekcje Reklama - %.1f sekundy + %.2f sekundy Nieaktywne Wyreżyserowane przez %1$s @@ -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 @@ -349,4 +349,240 @@ 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 From 3947f9ff26281a494346d7312556688e01157433 Mon Sep 17 00:00:00 2001 From: Chosnuk Date: Mon, 13 Apr 2026 04:30:57 +0000 Subject: [PATCH 20/92] Translated using Weblate (Polish) Currently translated at 100.0% (538 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 41 ++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index a4162b9f..9852b679 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -215,7 +215,7 @@ %d godzin Typ obrazu - + Domyślne skalowanie Odtwarzaj bezpośrednio napisy ASS Odtwarzaj bezpośrednio napisy PGS @@ -316,7 +316,7 @@ Ponów Wideo Filmy - Oglądanie na żywo + Oglądaj na żywo Urządzenie uwierzytelniono pomyślnie Hasło Nazwa użytkownika @@ -585,4 +585,41 @@ 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 From 2e40ac62af541314cda256e6fd838f61bcc91faa Mon Sep 17 00:00:00 2001 From: TheMatrixan Date: Mon, 13 Apr 2026 19:08:32 +0000 Subject: [PATCH 21/92] Translated using Weblate (Polish) Currently translated at 100.0% (538 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 62 +++++++++++++------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 9852b679..8aaa82df 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 @@ -133,10 +133,10 @@ - Czołówki - - - + Czołówka + Czołówki + Czołówek + Czołówek Utwórz nową playliste Dodaj do playlisty @@ -182,9 +182,9 @@ Czcionka kursywy Za kulisami - - - + Za kulisami + Za kulisami + Za kulisami Ustawienia zaawansowane Sprawdź dostępność aktualizacji @@ -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,7 +315,7 @@ Błąd serwera Ponów Wideo - Filmy + Widea Oglądaj na żywo Urządzenie uwierzytelniono pomyślnie Hasło @@ -475,9 +475,9 @@ Przekroczono limit czasu rozpoznawania głosu Piosenki tematyczne - - - + + + Przełącz fokus na szufladę nawigacyjną Nadpisywanie odtwarzania @@ -591,27 +591,27 @@ Czas trwania Klipy - - - + + + Próbki - - - + + + Filmy krótkometrażowe - - - + + + Krótkie filmy - - - + + + Proporcje ekranu Aktorzy gościnni From 986589bae5fcca8641e5f4272e73f423627eafbf Mon Sep 17 00:00:00 2001 From: opakholis Date: Tue, 14 Apr 2026 07:03:51 +0000 Subject: [PATCH 22/92] Translated using Weblate (Indonesian) Currently translated at 100.0% (538 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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 From 3ba1fbc3b0bbbf6029b0ac59e449b07ddc67fc78 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Wed, 15 Apr 2026 00:15:47 +0000 Subject: [PATCH 23/92] Translated using Weblate (Italian) Currently translated at 100.0% (538 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 88a3da3c..d48e9a4f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -597,4 +597,11 @@ 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 From fd10ed966079a1c70d41e588a68b7a8d15346504 Mon Sep 17 00:00:00 2001 From: lednurb Date: Wed, 15 Apr 2026 05:59:49 +0000 Subject: [PATCH 24/92] Translated using Weblate (Dutch) Currently translated at 91.4% (492 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) 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 From 94712b81704f3d93614f57024bce44576644e236 Mon Sep 17 00:00:00 2001 From: shift19 Date: Tue, 14 Apr 2026 12:25:21 +0000 Subject: [PATCH 25/92] Translated using Weblate (Hungarian) Currently translated at 61.1% (329 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 3d1b62ed40bd36e05fb69f997a56d588d96beca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=84=82=F0=9D=95=A0=F0=9D=95=A0=F0=9D=95=A0=F0=9D=95=9D?= =?UTF-8?q?=20=28=F0=9D=95=98=F0=9D=95=9A=F0=9D=95=A5=F0=9D=95=99?= =?UTF-8?q?=F0=9D=95=A6=F0=9D=95=93=2E=F0=9D=95=94=F0=9D=95=A0=F0=9D=95=9E?= =?UTF-8?q?/=E2=84=82=F0=9D=95=A0=F0=9D=95=A0=F0=9D=95=A0=F0=9D=95=9D=29?= Date: Tue, 14 Apr 2026 17:57:30 +0000 Subject: [PATCH 26/92] Translated using Weblate (Latvian) Currently translated at 45.3% (244 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/lv/ --- app/src/main/res/values-lv/strings.xml | 49 +++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) 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 From 93ead0861d3006f41f6e37b8a0bf4b4f3175d0d6 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Wed, 15 Apr 2026 17:34:54 +0000 Subject: [PATCH 27/92] Translated using Weblate (Portuguese) Currently translated at 100.0% (538 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From 55c717e20161cc937d8fb56ec70471a9a5f3443a Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Wed, 15 Apr 2026 22:24:01 +0000 Subject: [PATCH 28/92] Translated using Weblate (Spanish) Currently translated at 100.0% (542 of 542 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index fa312ec6..f8e280da 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,8 @@ 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 From 1939b956329d7284c66c08048ff56e8218518b16 Mon Sep 17 00:00:00 2001 From: ymir Date: Wed, 15 Apr 2026 21:50:47 +0000 Subject: [PATCH 29/92] Translated using Weblate (French) Currently translated at 98.5% (534 of 542 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index cd74bb60..25a6d207 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -323,7 +323,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 @@ -550,7 +550,7 @@ 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é From eb4f42d215990e1b95505bb0714c818ced260cfd Mon Sep 17 00:00:00 2001 From: Alan700 Date: Wed, 15 Apr 2026 21:50:47 +0000 Subject: [PATCH 30/92] Translated using Weblate (French) Currently translated at 98.5% (534 of 542 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 25a6d207..97debcc2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -545,7 +545,7 @@ Miniature Image avec couleur dynamique Image uniquement - Saisir l\'URL et la clé API + Clé API Se connecter au serveur Seerr Utilisateur Jellyfin From 42515f3b79d71e41a0958a8a7accfce7c03e2d00 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Thu, 16 Apr 2026 01:23:31 +0000 Subject: [PATCH 31/92] Translated using Weblate (Italian) Currently translated at 100.0% (542 of 542 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d48e9a4f..57975479 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 @@ -604,4 +604,8 @@ 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 From 1a8309767f27d11cf0271204a6e17dab68121432 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Thu, 16 Apr 2026 01:04:55 +0000 Subject: [PATCH 32/92] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (542 of 542 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 921f46c8..4a5f9e18 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,8 @@ 从即将播放中移除剧集 查看已从即将播放中移除的剧集 %1$s 制片公司 + 通过 libass 直接播放 + 通过内置的 ExoPlayer 直接播放 + 服务器端烧录/转码 + SSA/ASS 字幕播放 From 6546d1d3fb61988d8908f2dda89adc70c335787c Mon Sep 17 00:00:00 2001 From: oyvhov Date: Wed, 15 Apr 2026 21:50:53 +0000 Subject: [PATCH 33/92] Translated using Weblate (Norwegian Nynorsk) Currently translated at 61.6% (334 of 542 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nn/ --- app/src/main/res/values-nn/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 2d4ff6600ef74500be48f86024ccc63853e782c6 Mon Sep 17 00:00:00 2001 From: RafaelSoaresP Date: Wed, 15 Apr 2026 21:50:55 +0000 Subject: [PATCH 34/92] Translated using Weblate (Portuguese (Brazil)) Currently translated at 91.5% (496 of 542 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt_BR/ --- app/src/main/res/values-pt-rBR/strings.xml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 + + + + + + From f42ed8bb09b5631bfa83e52a5dbb06aec1563c6b Mon Sep 17 00:00:00 2001 From: jbrek Date: Wed, 15 Apr 2026 21:50:59 +0000 Subject: [PATCH 35/92] Translated using Weblate (Thai) Currently translated at 95.3% (517 of 542 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/th/ --- app/src/main/res/values-th/strings.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 42948ac7..91025839 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -31,7 +31,7 @@ ผู้กำกับ ปิดใช้งาน เซิร์ฟเวอร์ที่ค้นพบ - ดาวน์โหลดและอัปเดต + กำลังดาวน์โหลด… ใช้งาน จบที่ %1$s @@ -104,7 +104,7 @@ เริ่มใหม่ เล่นเกมต่อ บันทึก - ค้นหาและดาวน์โหลด + ค้นหา ค้นหา… ค้นหาด้วยเสียง @@ -225,7 +225,7 @@ เล่นอัตโนมัติถัดไป ตรวจสอบอัปเดต ใช้ได้กับซีรีส์โทรทัศน์เท่านั้น - รวมตัวเลือก \"ดูต่อ\" และ \"รายการถัดไป\" เข้าด้วยกัน + เล่นคำบรรยาย ASS โดยตรง เล่นคำบรรยาย PGS โดยตรง ลดระดับเสียงลงเป็นสเตอริโอเสมอ @@ -307,7 +307,7 @@ อัตราส่วนภาพ แสดงรายละเอียด ประเภทรูปภาพ - นักแสดงและทีมงาน + ดารารับเชิญ ขนาดขอบ การสลับอัตราการรีเฟรช @@ -505,12 +505,12 @@ นิ้วหัวแม่มือ ภาพที่มีสีแบบไดนามิก รูปภาพเท่านั้น - ป้อน URL และ API Key + คีย์ API เข้าสู่ระบบเซิร์ฟเวอร์ Seerr Jellyfin ผู้ใช้ ผู้ใช้ท้องถิ่น - ค้นหาและดาวน์โหลดคำบรรยาย + ไม่พบคำบรรยายระยะไกล ปัจจุบัน ใช้โปรแกรมรักษาหน้าจอในแอป From 6d895042d8d2f0aa60e84becb50e625905eee98d Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Thu, 16 Apr 2026 09:53:26 +0000 Subject: [PATCH 36/92] Translated using Weblate (Spanish) Currently translated at 100.0% (543 of 543 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f8e280da..59522eee 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -608,4 +608,5 @@ Reproducción directa con ExoPlayer Incrustar/transcodificar en el servidor Reproducción de subtítulos SSA/ASS + Última reproducción From e06170919cb0a12060257abffffcc82592845426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Thu, 16 Apr 2026 10:07:24 +0000 Subject: [PATCH 37/92] Translated using Weblate (Estonian) Currently translated at 100.0% (543 of 543 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 3d535faf..41c25a2f 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,9 @@ Leia telesaateid Leia filme Stuudiod: %1$s + Viimati esitatud + Otseesitus libass teegiga + Otseesitus sisseehitatud ExoPlayer teegiga + Kodeeri serveris + SSA/ASS subtiitrite esitus From ac9c49a7b3c2232f0448f70cc62ed51eab30ea67 Mon Sep 17 00:00:00 2001 From: idezentas Date: Thu, 16 Apr 2026 13:59:14 +0000 Subject: [PATCH 38/92] Translated using Weblate (Turkish) Currently translated at 99.8% (542 of 543 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index e1f87f9e..7e8b4aa5 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -595,4 +595,9 @@ 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 From 6fd7d4e1cb9bc794893945c7364dd9215de3b163 Mon Sep 17 00:00:00 2001 From: Wit Krittawit Date: Thu, 16 Apr 2026 11:16:02 +0000 Subject: [PATCH 39/92] Translated using Weblate (Thai) Currently translated at 93.9% (510 of 543 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/th/ --- app/src/main/res/values-th/strings.xml | 37 ++++++++++++++------------ 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 91025839..985eb10d 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -21,7 +21,7 @@ การให้คะแนนของชุมชน เกิดข้อผิดพลาด! กดปุ่มเพื่อส่งบันทึกไปยังเซิร์ฟเวอร์ของคุณ ตกลง - ดูต่อครับ + ดูต่อ การจัดอันดับนักวิจารณ์ %.2f วินาที ค่าเริ่มต้น @@ -34,7 +34,7 @@ กำลังดาวน์โหลด… ใช้งาน - จบที่ %1$s + จบเมื่อ %1$s ป้อนที่อยู่ IP หรือ URL ของเซิร์ฟเวอร์ ป้อนที่อยู่เซิร์ฟเวอร์ ตอน @@ -68,7 +68,7 @@ ภาพยนตร์ ชื่อ - ถัดไปขึ้นมา + ตอนถัดไป ไม่มีข้อมูล ไม่มีผลลัพธ์ ไม่พบเซิร์ฟเวอร์ @@ -102,7 +102,7 @@ บันทึกซีรีส์ ไม่เป็นที่ชื่นชอบ เริ่มใหม่ - เล่นเกมต่อ + เล่นต่อ บันทึก ค้นหา @@ -128,7 +128,7 @@ เลือกเซิร์ฟเวอร์ เลือกผู้ใช้งาน แสดงข้อมูลการแก้ไขข้อบกพร่อง - แสดงต่อไป + แสดงตอนถัดไป แสดง สุ่ม ข้าม @@ -146,21 +146,21 @@ คำแนะนำ สลับเซิร์ฟเวอร์ สวิตช์ - เรตติ้งสูงสุด (ยังไม่มีคนดู) + รายการที่ได้คะแนนสูงที่ยังไม่ได้ดู ตัวอย่าง - รถพ่วง + ตัวอย่าง - กำหนดการ DVR - แนะนำ - ฤดูกาล - ฤดูกาล + กำหนดการบันทึก + ผังรายการ + ซีซั่น + ซีซั่น รายการทีวี อินเทอร์เฟซ ไม่มีข้อมูล - มีอัพเดต + อัปเดต เวอร์ชัน สเกลวิดีโอ วิดีโอ @@ -221,10 +221,10 @@ UI ขั้นสูง ธีมแอปพลิเคชัน ตรวจสอบการอัปเดตโดยอัตโนมัติ - หน่วงเวลาก่อนเล่นเพลงถัดไป - เล่นอัตโนมัติถัดไป - ตรวจสอบอัปเดต - ใช้ได้กับซีรีส์โทรทัศน์เท่านั้น + หน่วงเวลาก่อนเล่นตอนถัดไป + เล่นตอนถัดไปอัตโนมัติ + ตรวจสอบการอัปเดต + ใช้กับรายการโทรทัศน์เท่านั้น เล่นคำบรรยาย ASS โดยตรง เล่นคำบรรยาย PGS โดยตรง @@ -274,7 +274,7 @@ การเล่นแบ็กเอนด์ MPV: ใช้การถอดรหัสฮาร์ดแวร์ ปิดใช้งานหากพบปัญหาโปรแกรมหยุดทำงาน - MPV ตัวเลือก + ตัวเลือก MPV ตัวเลือก ExoPlayer ข้ามส่วน ข้ามโฆษณา @@ -548,4 +548,7 @@ เลือกทั้งหมด แยกประเภท ควรแสดงโลโก้แทนชื่อเรื่อง + Wolphin อัปเดตเวอร์ชั่นเป็น %s แล้ว + นำออกจากรายการดูต่อ + นำซีรีส์ออกจากรายการถัดไป From 91a75606df1325937e6d3d3dff91dfa601eea5aa Mon Sep 17 00:00:00 2001 From: SimonHung Date: Fri, 17 Apr 2026 03:05:01 +0000 Subject: [PATCH 40/92] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (544 of 544 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 35d3502b..c4baff7e 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,10 @@ 從接下來播放中移除劇集 查看從接下來播放中移除的劇集 %1$s 製作公司 + 使用 libass 播放 + 使用 ExoPlayer 內建播放 + 由伺服器燒錄/轉碼 + SSA/ASS 字幕播放方式 + 上次播放 + 不顯示 From 371925aa72eb86b018cf8938e8b2e92059d2aa1c Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Fri, 17 Apr 2026 09:01:34 +0000 Subject: [PATCH 41/92] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (544 of 544 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4a5f9e18..6966d79a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -572,4 +572,6 @@ 通过内置的 ExoPlayer 直接播放 服务器端烧录/转码 SSA/ASS 字幕播放 + 从不 + 上次播放 From 6e6df53d0cb806824dcc6f5006a8e1257e4b554d Mon Sep 17 00:00:00 2001 From: MrDog Date: Fri, 17 Apr 2026 09:13:15 +0000 Subject: [PATCH 42/92] Added translation using Weblate (Slovenian) --- app/src/main/res/values-sl/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-sl/strings.xml 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..55344e51 --- /dev/null +++ b/app/src/main/res/values-sl/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 2c0bf94f4203262698e6f6ceddeef3c8fa16b603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Fri, 17 Apr 2026 13:23:52 +0000 Subject: [PATCH 43/92] Translated using Weblate (Estonian) Currently translated at 100.0% (544 of 544 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 41c25a2f..c5ed2461 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -591,4 +591,5 @@ Otseesitus sisseehitatud ExoPlayer teegiga Kodeeri serveris SSA/ASS subtiitrite esitus + Mitte kunagi From 504c74e0c6a3d7ad624882ca44d21f3c2c762049 Mon Sep 17 00:00:00 2001 From: MrDog Date: Fri, 17 Apr 2026 09:31:31 +0000 Subject: [PATCH 44/92] Translated using Weblate (Slovenian) Currently translated at 13.6% (74 of 544 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sl/ --- app/src/main/res/values-sl/strings.xml | 78 +++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 55344e51..a266908e 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -1,3 +1,79 @@ - \ No newline at end of file + 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 + From 73edbbc01dd90abda799f5ed907aa9ff9419a2c8 Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Sat, 18 Apr 2026 13:15:22 +0000 Subject: [PATCH 45/92] Translated using Weblate (German) Currently translated at 100.0% (544 of 544 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9be68c5a..907eda68 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 @@ -586,4 +586,10 @@ 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 From e0b4cf17bb2a2954032f66ffdce88a971b668e12 Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Fri, 17 Apr 2026 20:05:34 +0000 Subject: [PATCH 46/92] Translated using Weblate (Spanish) Currently translated at 100.0% (544 of 544 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 59522eee..29a87b08 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -609,4 +609,5 @@ Incrustar/transcodificar en el servidor Reproducción de subtítulos SSA/ASS Última reproducción + Nunca From 72b956a1c45287a5a29dc687483d804f33f388db Mon Sep 17 00:00:00 2001 From: idezentas Date: Sat, 18 Apr 2026 12:49:28 +0000 Subject: [PATCH 47/92] Translated using Weblate (Turkish) Currently translated at 100.0% (544 of 544 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 7e8b4aa5..c5052883 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -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 @@ -600,4 +600,5 @@ Yerleşik ExoPlayer ile doğrudan oynatma Sunucuda dönüştür ve işle SSA/ASS altyazı oynatma + Asla From 3210ad110ed4eba3941ceb3465cd4f82726fb810 Mon Sep 17 00:00:00 2001 From: Wit Krittawit Date: Sat, 18 Apr 2026 16:31:07 +0000 Subject: [PATCH 48/92] Translated using Weblate (Thai) Currently translated at 93.7% (510 of 544 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/th/ --- app/src/main/res/values-th/strings.xml | 38 +++++++++++++------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 985eb10d..c1f2fd7a 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -43,7 +43,7 @@ ชื่นชอบ ขนาด บังคับ - ประเภทเพลง + แนวเนื้อหา ไปซีรีย์ ไปที่ ซ่อนการควบคุมการเล่น @@ -53,7 +53,7 @@ ทันที บทนำ #ABCDEFGHIJKLMNOPQRSTUVWXYZ - คลังเพลง + คลัง ข้อมูลใบอนุญาต ทีวี สด กำลังโหลด… @@ -62,7 +62,7 @@ ทำเครื่องหมายว่ายังไม่ได้ดู ทำเครื่องหมายว่าดูแล้ว บิตเรตสูงสุด - เช่นนี้มากขึ้น + เนื้อหาอื่นที่คล้ายกัน เพิ่มเติม ภาพยนตร์ @@ -93,7 +93,7 @@ การตั้งค่าโปรไฟล์ผู้ใช้ คิว สรุป - ที่เพิ่งเพิ่ม %1$s + ที่เพิ่งเพิ่มใน %1$s ที่เพิ่งเพิ่ม บันทึกล่าสุด ที่เพิ่งวางขาย @@ -143,7 +143,7 @@ การดาวน์โหลดใช้เวลานาน คุณอาจต้องเริ่มเล่นใหม่ คำบรรยาย คำบรรยาย - คำแนะนำ + รายการแนะนำ สลับเซิร์ฟเวอร์ สวิตช์ รายการที่ได้คะแนนสูงที่ยังไม่ได้ดู @@ -167,7 +167,7 @@ วิดีโอ ชมสด %1$d ปี - เล่นกับการแปลงรหัส + เล่นโดยใช้การแปลงรหัส ข้อมูลสื่อ แสดงนาฬิกา ลำดับตอนที่ออกอากาศ @@ -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 คอมแพค ภาพขนาดย่อของซีรี่ส์ ภาพขนาดย่อของตอน - ต่ำสุด - ต่ำ + เบาสุด + เบา ปานกลาง - สูง - เต็มดาว - สีม่วง - สีส้ม + ดัง + ดังสุด + ม่วง + ส้ม ฟ้าใส ดำ ไม่สนใจ From e6bc4e3c19b0a82568a4cd709aa8e651664f4325 Mon Sep 17 00:00:00 2001 From: Alan700 Date: Sat, 18 Apr 2026 22:05:22 +0000 Subject: [PATCH 49/92] Translated using Weblate (French) Currently translated at 100.0% (544 of 544 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 97debcc2..7b67ce31 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -604,4 +604,10 @@ 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 From 346349910ae7cc9442c1664437d009537666cc7f Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Mon, 20 Apr 2026 03:47:15 +0000 Subject: [PATCH 50/92] Translated using Weblate (German) Currently translated at 100.0% (546 of 546 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 907eda68..6b392116 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -592,4 +592,6 @@ Direkte Wiedergabe mit ExoPlayer (eingebaut) Einbrennen/Auf Server transkodieren SSA/ASS Untertitel Wiedergabe + Externer Player + Systemstandard From 90e932a804ffd38f1ced634fd45ffa8b02d415b2 Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Mon, 20 Apr 2026 05:35:16 +0000 Subject: [PATCH 51/92] Translated using Weblate (Spanish) Currently translated at 100.0% (546 of 546 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 29a87b08..8b524b64 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -610,4 +610,6 @@ Reproducción de subtítulos SSA/ASS Última reproducción Nunca + Reproductor externo + Predeterminado del sistema From 591214fe94fcf2adfb52812f5c72ddbf4b80fbfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Sun, 19 Apr 2026 23:31:28 +0000 Subject: [PATCH 52/92] Translated using Weblate (Estonian) Currently translated at 100.0% (546 of 546 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index c5ed2461..bfac1ea3 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -588,8 +588,10 @@ Stuudiod: %1$s Viimati esitatud Otseesitus libass teegiga - Otseesitus sisseehitatud ExoPlayer teegiga + Otseesitus sisseehitatud ExoPlayeri teegiga Kodeeri serveris SSA/ASS subtiitrite esitus Mitte kunagi + Väline meediapleier + Süsteemi vaikeväärtus From 6e08f065272e0d3c35811f568a3286280df2acc4 Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 20 Apr 2026 08:34:04 +0000 Subject: [PATCH 53/92] Translated using Weblate (French) Currently translated at 100.0% (546 of 546 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7b67ce31..7951e871 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -610,4 +610,6 @@ 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 From dd0fda557332275cb7a6dbd9c0214bb372cab010 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Mon, 20 Apr 2026 01:21:56 +0000 Subject: [PATCH 54/92] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (546 of 546 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c4baff7e..5e699bcd 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -574,4 +574,6 @@ SSA/ASS 字幕播放方式 上次播放 不顯示 + 外部播放器 + 系統預設 From 23a45016eb70c0d2ddaaeea63f0d040ead910fd7 Mon Sep 17 00:00:00 2001 From: TheMatrixan Date: Mon, 20 Apr 2026 05:21:42 +0000 Subject: [PATCH 55/92] Translated using Weblate (Polish) Currently translated at 100.0% (546 of 546 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 8aaa82df..0b06bfe1 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -217,7 +217,7 @@ 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 @@ -622,4 +622,12 @@ 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 From ed5a6b3aaf5c84743eb8bc2159f39a9d770adfb8 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Mon, 20 Apr 2026 00:58:19 +0000 Subject: [PATCH 56/92] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (546 of 546 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6966d79a..0825efbc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -574,4 +574,6 @@ SSA/ASS 字幕播放 从不 上次播放 + 外部播放器 + 系统默认 From 4b5b18247d8da5cc1c1ac1ee6cb1f418848be652 Mon Sep 17 00:00:00 2001 From: idezentas Date: Sun, 19 Apr 2026 21:13:53 +0000 Subject: [PATCH 57/92] Translated using Weblate (Turkish) Currently translated at 100.0% (546 of 546 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index c5052883..acc3e63f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -526,7 +526,7 @@ Yerel kullanıcı Uzak altyazı bulunamadı - Mevcut + Günümüz %s dakika %s dakika @@ -601,4 +601,6 @@ Sunucuda dönüştür ve işle SSA/ASS altyazı oynatma Asla + Harici Oynatıcı + Sistem varsayılanı From 116b4516a21f9fbad4126c2d56aa00524d10c3d1 Mon Sep 17 00:00:00 2001 From: Alan700 Date: Tue, 21 Apr 2026 11:52:02 +0000 Subject: [PATCH 58/92] Translated using Weblate (French) Currently translated at 99.8% (545 of 546 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7951e871..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 From 9bd10a9880231c7ca4489278b848f5e265d66bb3 Mon Sep 17 00:00:00 2001 From: MrDog Date: Tue, 21 Apr 2026 19:23:59 +0000 Subject: [PATCH 59/92] Translated using Weblate (Slovenian) Currently translated at 20.8% (114 of 546 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sl/ --- app/src/main/res/values-sl/strings.xml | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index a266908e..c9312ee9 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -76,4 +76,49 @@ 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 From 632cf6bae49f1aa8435a2c2f7635962d8060eb54 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Wed, 22 Apr 2026 17:30:45 +0000 Subject: [PATCH 60/92] Translated using Weblate (Italian) Currently translated at 99.6% (544 of 546 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 57975479..475d1bc4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -608,4 +608,6 @@ Riproduzione diretta con ExoPlayer integrato Masterizza/trascodifica sul server Riproduzione sottotitoli SSA/ASS + Mai + Player esterno From 28047cceee1dfa7352ad929951e8a7b5d809080a Mon Sep 17 00:00:00 2001 From: MrDog Date: Tue, 21 Apr 2026 23:08:29 +0000 Subject: [PATCH 61/92] Translated using Weblate (Slovenian) Currently translated at 22.8% (125 of 546 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sl/ --- app/src/main/res/values-sl/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index c9312ee9..6b9b82f5 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -121,4 +121,15 @@ 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 From d68a5dfb85556f8f333b2bdc634d5c902b9a26cf Mon Sep 17 00:00:00 2001 From: arcker95 Date: Wed, 22 Apr 2026 17:44:09 +0000 Subject: [PATCH 62/92] Translated using Weblate (Italian) Currently translated at 100.0% (546 of 546 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 475d1bc4..3a836161 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -610,4 +610,6 @@ Riproduzione sottotitoli SSA/ASS Mai Player esterno + Predefinito sistema + Ultima riproduzione From e71cb4aaae9b661b235df6e60061644331682ea0 Mon Sep 17 00:00:00 2001 From: zyplex Date: Sat, 25 Apr 2026 09:08:42 +0000 Subject: [PATCH 63/92] Translated using Weblate (German) Currently translated at 99.6% (547 of 549 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6b392116..46fe0f1f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -594,4 +594,5 @@ SSA/ASS Untertitel Wiedergabe Externer Player Systemstandard + Zeige %1$s mehr From ac85008a7bccf56a9eb311907b8a77c2f13f3f3c Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Fri, 24 Apr 2026 22:22:05 +0000 Subject: [PATCH 64/92] Translated using Weblate (Spanish) Currently translated at 100.0% (549 of 549 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8b524b64..95ec1633 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -612,4 +612,7 @@ Nunca Reproductor externo Predeterminado del sistema + Muestra %1$s más + Comportamiento al saltar + Para cabecera, créditos finales, etc From a65f9ecd6c4756ab2882980bc3bdb92590230253 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Sat, 25 Apr 2026 18:26:01 +0000 Subject: [PATCH 65/92] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 99.8% (548 of 549 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 5e699bcd..589115d8 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -576,4 +576,6 @@ 不顯示 外部播放器 系統預設 + 跳過行為 + 用於片頭、片尾等段落 From 8ae7d7bd1a428b9286159a4d9a14c125bbe99790 Mon Sep 17 00:00:00 2001 From: idezentas Date: Sat, 25 Apr 2026 14:58:24 +0000 Subject: [PATCH 66/92] Translated using Weblate (Turkish) Currently translated at 100.0% (549 of 549 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index acc3e63f..3e5f4a87 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 @@ -603,4 +603,7 @@ Asla Harici Oynatıcı Sistem varsayılanı + Atlama Davranışı + İntrolar, jenerikler vb. için + %1$s daha göster From e82a8632fc60afb5cc24b0b36f6c752816df1362 Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Sat, 25 Apr 2026 18:51:49 +0000 Subject: [PATCH 67/92] Translated using Weblate (German) Currently translated at 100.0% (549 of 549 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 46fe0f1f..63f84ae9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -595,4 +595,6 @@ Externer Player Systemstandard Zeige %1$s mehr + \"Überspringen\" Verhalten + Für Intros, Credits, etc From dc56296082fa1f9e107996dd390a9a40d0df475f Mon Sep 17 00:00:00 2001 From: SimonHung Date: Sun, 26 Apr 2026 03:41:38 +0000 Subject: [PATCH 68/92] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (549 of 549 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 589115d8..a79f173a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -578,4 +578,5 @@ 系統預設 跳過行為 用於片頭、片尾等段落 + 顯示更多 (%1$s 筆) From 6c2769f84ab2bd7a4a28e681e1673219d850732f Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Sun, 26 Apr 2026 00:36:32 +0000 Subject: [PATCH 69/92] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (549 of 549 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0825efbc..35eac8d6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -576,4 +576,7 @@ 上次播放 外部播放器 系统默认 + 显示更多 %1$s 条 + 跳过行为 + 适用于片头、片尾等 From eeb237350767bea4701184aacf497f7995248747 Mon Sep 17 00:00:00 2001 From: MrDog Date: Sun, 26 Apr 2026 09:18:59 +0000 Subject: [PATCH 70/92] Translated using Weblate (Slovenian) Currently translated at 32.6% (179 of 549 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sl/ --- app/src/main/res/values-sl/strings.xml | 89 ++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 6b9b82f5..d9fcbefc 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -132,4 +132,93 @@ 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 + From b2ecc2886f2d198876dc6ac5459b8d092b664664 Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Sun, 26 Apr 2026 16:15:34 +0000 Subject: [PATCH 71/92] Translated using Weblate (Spanish) Currently translated at 100.0% (550 of 550 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 95ec1633..2dc0c213 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -615,4 +615,5 @@ Muestra %1$s más Comportamiento al saltar Para cabecera, créditos finales, etc + Modo cine From c616d58ce576fb5078e4bcd2da38bd227f64ecd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Sun, 26 Apr 2026 16:41:29 +0000 Subject: [PATCH 72/92] Translated using Weblate (Estonian) Currently translated at 100.0% (550 of 550 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index bfac1ea3..02f3e0a0 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -594,4 +594,8 @@ 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 From 4d81d31e8ddb6fe626784c3a138baad6a84ba3b0 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Sun, 26 Apr 2026 14:54:19 +0000 Subject: [PATCH 73/92] Translated using Weblate (Italian) Currently translated at 100.0% (550 of 550 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3a836161..69051c90 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -612,4 +612,8 @@ Player esterno Predefinito sistema Ultima riproduzione + Modalità cinema + Mostra %1$s altri + Comportamento di salto + Per intro, titoli di coda e altro From a46cb36735ec5e91f0e463d78e53c3aa50dff62d Mon Sep 17 00:00:00 2001 From: idezentas Date: Sun, 26 Apr 2026 16:02:15 +0000 Subject: [PATCH 74/92] Translated using Weblate (Turkish) Currently translated at 100.0% (550 of 550 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 3e5f4a87..5bb03949 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -606,4 +606,5 @@ Atlama Davranışı İntrolar, jenerikler vb. için %1$s daha göster + Sinema Modu From a15952629611030f41914fb3e6b7a77574618674 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 27 Apr 2026 18:14:00 -0400 Subject: [PATCH 75/92] Release v0.6.3 From c718d903a4ca1abc46c73387e786995d3b41da3c Mon Sep 17 00:00:00 2001 From: Damontecres <154766448+damontecres@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:58:54 -0400 Subject: [PATCH 76/92] Refactor context menu code (#1302) ## Description This PR refactors most of the context menu dialog code moving more of the display into a Composable. This then allows for embedding the confirm delete or choose version sub-dialogs. This will allow for easier future expansion of the context menu. Now instead of the page using a combination of `DiaglogPopup` & `buildMoreFor` functions, pages create a `ContextMenu` object and pass it to a composable. There's no significant user facing changes. ### Related issues None ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../ui/components/CollectionFolderGrid.kt | 121 +-- .../wholphin/ui/components/ContextMenu.kt | 861 ++++++++++++++++++ .../wholphin/ui/components/Dialogs.kt | 21 +- .../wholphin/ui/components/PlayButtons.kt | 31 +- .../ui/components/RecommendedContent.kt | 116 +-- .../ui/components/RecommendedMovie.kt | 3 +- .../ui/components/RecommendedMusic.kt | 3 +- .../ui/components/RecommendedTvShow.kt | 3 +- .../wholphin/ui/detail/DetailUtils.kt | 259 ------ .../wholphin/ui/detail/PlaylistDetails.kt | 127 ++- .../ui/detail/collection/CollectionButtons.kt | 8 +- .../ui/detail/collection/CollectionDetails.kt | 242 ++--- .../detail/collection/CollectionViewModel.kt | 21 +- .../ui/detail/episode/EpisodeDetails.kt | 181 ++-- .../wholphin/ui/detail/movie/MovieDetails.kt | 230 ++--- .../ui/detail/music/AlbumDetailsPage.kt | 144 ++- .../ui/detail/music/ArtistDetailsPage.kt | 169 ++-- .../ui/detail/music/NowPlayingPage.kt | 58 +- .../wholphin/ui/detail/music/Utils.kt | 199 ---- .../ui/detail/series/FocusedEpisodeFooter.kt | 5 +- .../ui/detail/series/SeriesDetails.kt | 211 ++--- .../ui/detail/series/SeriesOverview.kt | 235 ++--- .../ui/detail/series/SeriesOverviewContent.kt | 4 +- .../damontecres/wholphin/ui/main/HomePage.kt | 100 +- .../wholphin/ui/nav/Destination.kt | 2 + 25 files changed, 1707 insertions(+), 1647 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt 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 848288d5..c7a8fecf 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,13 @@ 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.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 +134,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 +143,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?, @@ -662,18 +664,58 @@ 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 -> - moreDialog.makePresent(PositionItem(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 -> @@ -802,47 +844,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 = { - gridActions.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 -> @@ -862,16 +879,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) 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..22fd640f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -0,0 +1,861 @@ +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 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.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) } + + 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) + } + }, + 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, + ), + ) + }, + preferredSubtitleLanguage = preferredSubtitleLanguage, + ) + } + }, + onShowOverview = { actions.onShowOverview.invoke(item) }, + onClearChosenStreams = { + actions.onClearChosenStreams.invoke(chosenStreams) + }, + onClickDelete = { showDeleteDialog = 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() + }, + ) + } +} + +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, +): 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_transcoding), + Icons.Default.PlayArrow, + dismissOnClick = true, + ) { + actions.navigateTo( + Destination.Playback( + item.id, + item.resumeMs ?: 0L, + forceTranscoding = true, + ), + ) + }, + ) + } + 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) + }, + ) + } + } + +@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/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/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/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index ff5809fb..f498a497 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 @@ -72,14 +72,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 +88,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 +370,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 +403,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 +453,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 +513,6 @@ fun PlaylistDetails( elevation = 3.dp, ) } - showDeleteDialog?.let { item -> - ConfirmDeleteDialog( - itemTitle = item.title ?: "", - onCancel = { showDeleteDialog = null }, - onConfirm = { - viewModel.deleteItem(item) - showDeleteDialog = null - }, - ) - } } @Composable 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 2f61fa4f..332e2a3f 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,6 +1,5 @@ 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 @@ -15,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 @@ -32,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 @@ -43,10 +38,9 @@ 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 @@ -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 { @@ -216,37 +213,21 @@ fun CollectionDetails( } ?: 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 +240,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 +275,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 +293,7 @@ fun CollectionDetailsContent( onClickViewOptions: () -> Unit, overviewOnClick: () -> Unit, favoriteOnClick: () -> Unit, - deleteOnClick: () -> Unit, + onConfirmDelete: () -> Unit, canDelete: Boolean, moreOnClick: () -> Unit, modifier: Modifier, @@ -423,7 +392,7 @@ fun CollectionDetailsContent( getPossibleFilterValues = getPossibleFilterValues, onClickViewOptions = onClickViewOptions, favoriteOnClick = favoriteOnClick, - deleteOnClick = deleteOnClick, + onConfirmDelete = onConfirmDelete, canDelete = canDelete, moreOnClick = moreOnClick, modifier = @@ -485,60 +454,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/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/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/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/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt index 53cdb444..173ba49b 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 @@ -308,22 +308,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) { @@ -402,23 +401,18 @@ 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, ) }, ) @@ -459,45 +453,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 +543,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 +576,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 +601,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..77f2e063 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 @@ -312,22 +312,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, ) } @@ -393,23 +392,18 @@ 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, ) }, ) @@ -451,45 +445,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 +500,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 +567,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 +600,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 +625,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/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index d7da35ed..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,9 +60,10 @@ 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.PlaybackKeyHandler @@ -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 5762e55b..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 @@ -110,6 +100,7 @@ fun SeriesOverview( 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 { @@ -119,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 = @@ -179,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, @@ -319,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 @@ -349,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 = { @@ -374,13 +327,20 @@ fun SeriesOverview( 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, @@ -391,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), @@ -428,17 +368,6 @@ 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 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 d87d339c..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 @@ -95,7 +95,7 @@ fun SeriesOverviewContent( overviewOnClick: () -> Unit, personOnClick: (Person) -> Unit, canDelete: (BaseItem) -> Boolean, - deleteOnClick: (BaseItem) -> Unit, + onConfirmDelete: (BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, modifier: Modifier = Modifier, ) { @@ -322,7 +322,7 @@ fun SeriesOverviewContent( } }, canDelete = canDelete.invoke(ep), - deleteOnClick = { deleteOnClick.invoke(ep) }, + onConfirmDelete = { onConfirmDelete.invoke(ep) }, modifier = Modifier .padding(top = 4.dp) 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..47a86748 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 @@ -34,6 +34,7 @@ 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.input.key.KeyEvent import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -56,24 +57,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 +120,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 +145,45 @@ 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) }, - onClickRemoveFromNextUp = viewModel::removeFromNextUp, + onChooseVersion = { _, _ -> + // Not supported on this page + }, + onChooseTracks = { + // Not supported on this page + }, + onShowOverview = { + overviewDialog = ItemDetailsDialogInfo(it) + }, + onClearChosenStreams = {}, ), ) - dialog = - DialogParams( - title = item.title ?: "", - fromLongClick = true, - items = dialogItems, - ) } } val onClickPlay = @@ -203,10 +206,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 +238,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 -> {} } } @@ -406,7 +406,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/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index a7559914..9c0596f3 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 @@ -9,6 +9,7 @@ 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 @@ -80,6 +81,7 @@ sealed class Destination( 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) } From b94faceb2f6498cf2e9769b4a38176799d222c14 Mon Sep 17 00:00:00 2001 From: Damontecres <154766448+damontecres@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:45:37 -0400 Subject: [PATCH 77/92] Choose backend to play media in context menu (#1325) ## Description Adds a context menu option to choose the player backend or transcoding to start playback. This allows for temporarily swapping to another backend without having to updating the settings. ### Related issues A couple comments have suggested this, but I don't think there's a specific feature request ### Testing Emulator ## Screenshots play_with Large ## AI or LLM usage None --- .../wholphin/ui/components/ContextMenu.kt | 78 ++++++++++++++++--- .../wholphin/ui/nav/DestinationContent.kt | 22 +++++- .../wholphin/ui/playback/PlaybackViewModel.kt | 5 +- app/src/main/res/values/strings.xml | 2 + 4 files changed, 91 insertions(+), 16 deletions(-) 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 index 22fd640f..e2111637 100644 --- 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 @@ -17,12 +17,14 @@ 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 @@ -185,6 +187,7 @@ fun ContextMenu( 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) { @@ -211,6 +214,7 @@ fun ContextMenu( ) { idx -> val source = item.data.mediaSources!![idx] actions.onChooseVersion.invoke(item, source) + onDismissRequest.invoke() } }, onChooseTracks = { type -> @@ -239,6 +243,7 @@ fun ContextMenu( itemPlayback = chosenStreams?.itemPlayback, ), ) + onDismissRequest.invoke() }, preferredSubtitleLanguage = preferredSubtitleLanguage, ) @@ -249,6 +254,7 @@ fun ContextMenu( actions.onClearChosenStreams.invoke(chosenStreams) }, onClickDelete = { showDeleteDialog = true }, + onClickPlayWith = { showPlayWithDialog = true }, ) } DialogPopup( @@ -281,6 +287,31 @@ fun ContextMenu( }, ) } + 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( @@ -302,6 +333,7 @@ private fun buildContextMenuItems( onShowOverview: () -> Unit, onClearChosenStreams: () -> Unit, onClickDelete: () -> Unit, + onClickPlayWith: () -> Unit, ): List = buildList { if (showGoTo) { @@ -316,7 +348,7 @@ private fun buildContextMenuItems( ) } if (item.type in supportedPlayableTypes) { - if (item.playbackPosition >= Duration.ZERO) { + if (item.playbackPosition > Duration.ZERO) { add( DialogItem( context.getString(R.string.resume), @@ -566,18 +598,11 @@ private fun buildContextMenuItems( if (item.type in supportedPlayableTypes) { add( DialogItem( - context.getString(R.string.play_with_transcoding), + context.getString(R.string.play_with), Icons.Default.PlayArrow, - dismissOnClick = true, - ) { - actions.navigateTo( - Destination.Playback( - item.id, - item.resumeMs ?: 0L, - forceTranscoding = true, - ), - ) - }, + dismissOnClick = false, + onClick = onClickPlayWith, + ), ) } if (item.data.mediaSources?.isNotEmpty() == true) { @@ -593,6 +618,35 @@ private fun buildContextMenuItems( } } +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, 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/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 680750c3..193bd08d 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 @@ -231,8 +231,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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63a83aae..afa848d6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -771,6 +771,8 @@ Show %1$s more Skip behavior For intros, credits, etc + Play with + Transcoding @string/ass_subtitle_mode_libass @string/ass_subtitle_mode_exoplayer From d538b8d4f93def079b3f3c0e721c52253f3759a3 Mon Sep 17 00:00:00 2001 From: Damontecres <154766448+damontecres@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:45:57 -0400 Subject: [PATCH 78/92] Preload grid card images & other minor optimizations (#1328) ## Description Preload ~6 rows worth of images on library grid pages. So depending on device CPU & memory, most cards do not show a placeholder image when scrolling unless you press and hold up/down to scroll as fast as possible. There's a couple other minor optimizations on the grid code as well, but nothing huge. ### Dev notes This PR makes the `cacheWindow` for pre-rendering cards applicable to their images. Previously, images would not be fetched until the card is laid out within the window. This was to get the width/height to pass to the server request. This PR instead calculates the card widths when the grid itself is laid out and then passes this width into the cards' composables. ### Related issues Related to #1246 ### Testing Tested with emulator, shield, fire stick 4k max ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/ui/CoilConfig.kt | 4 +- .../wholphin/ui/cards/EpisodeCard.kt | 4 + .../damontecres/wholphin/ui/cards/GridCard.kt | 4 + .../wholphin/ui/cards/ItemCardImage.kt | 23 +- .../ui/components/CollectionFolderGrid.kt | 21 +- .../wholphin/ui/components/GenreCardGrid.kt | 2 +- .../wholphin/ui/components/ItemGrid.kt | 3 +- .../wholphin/ui/components/StudioCardGrid.kt | 2 +- .../wholphin/ui/detail/CardGrid.kt | 213 ++++++++++-------- .../wholphin/ui/detail/PlaylistDetails.kt | 7 +- .../detail/collection/CollectionMixedGrid.kt | 3 +- .../ui/detail/discover/DiscoverPersonPage.kt | 2 +- .../ui/discover/DiscoverRequestGrid.kt | 2 +- .../wholphin/ui/discover/SeerrRequestsPage.kt | 2 +- 14 files changed, 167 insertions(+), 125 deletions(-) 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 4c40ee2c..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 @@ -38,8 +38,8 @@ fun CoilConfig( } }, okHttpClient = okHttpClient, - debugLogging = false, - enableCache = true, + debugLogging = debugLogging, + enableCache = enableCache, ) /** 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/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/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index c7a8fecf..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 @@ -79,6 +79,7 @@ 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.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState @@ -1060,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, @@ -1069,6 +1070,7 @@ fun CollectionFolderGridContent( imageAspectRatio = viewOptions.aspectRatio.ratio, imageType = viewOptions.imageType, showTitle = viewOptions.showTitles, + fillWidth = widthPx, modifier = mod, ) }, @@ -1109,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, ) }, @@ -1129,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, ) }, @@ -1144,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, ) }, @@ -1159,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, ) }, 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/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/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/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index f498a497..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 @@ -781,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, @@ -843,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/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/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/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, From 6afcfb469bb554d61abfc96814268bdd4ffefb79 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 1 May 2026 19:12:23 -0400 Subject: [PATCH 79/92] Enable large heap (#1337) ## Description This PR simply enables the `largeHeap` flag to request more memory from the OS. I am a bit embarrassed that I haven't added this before now, but I had read that using `largeHeap` was a bandaid, not a solution. So I assumed Wholphin was doing something incorrect memory-wise. But after crawling through many, many heap dumps to analyze Wholphin's memory usage, and after further research, enabling this does make sense for handling high bitrate, high resolution videos. Furthermore, many other Jellyfin clients and other video apps like Plex and Prime Video have this enabled. So since I am pretty confident that Wholphin doesn't have any memory leaks, I feel good about enabling this. ### Related issues I believe this will fix many of the bug reports about crashes during 4K playback, but it's not a cure-all since it is up the device & OS to allocate the max memory. Fixes #333 Fixes #826 Fixes #1189 ### Testing Shield 2019, Fire 4k Max ## Screenshots N/A ## AI or LLM usage None --- app/src/main/AndroidManifest.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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"> Date: Wed, 6 May 2026 11:49:13 -0400 Subject: [PATCH 80/92] Fix bug where changing subtitles during playback doesn't work (#1352) ## Description Fixes an issue when changing subtitles during playback if you had previously explicitly chosen subtitles for that item but not chosen an audio track. ### Related issues I don't think there is a specific issue for this ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/nav/Destination.kt | 2 -- .../wholphin/ui/playback/PlaybackViewModel.kt | 33 ++++++++----------- .../ui/playback/TrackSelectionUtils.kt | 4 +-- 3 files changed, 16 insertions(+), 23 deletions(-) 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 9c0596f3..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,6 @@ 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 @@ -79,7 +78,6 @@ 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) { 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 193bd08d..4a73afd9 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 @@ -298,21 +298,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 } @@ -395,7 +392,6 @@ class PlaybackViewModel play( firstItem, positionMs, - itemPlayback, forceTranscoding, ) if (!played) { @@ -427,7 +423,6 @@ class PlaybackViewModel private suspend fun play( playlistItem: PlaylistItem, positionMs: Long, - itemPlayback: ItemPlayback? = null, forceTranscoding: Boolean = this.forceTranscoding, ): Boolean = withContext(Dispatchers.IO) { @@ -463,19 +458,18 @@ class PlaybackViewModel 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) { @@ -531,7 +525,7 @@ class PlaybackViewModel .chooseAudioStream( source = mediaSource, seriesId = base.seriesId, - itemPlayback = playbackConfig, + itemPlayback = itemPlayback, plc = plc, prefs = preferences, ) @@ -543,7 +537,7 @@ class PlaybackViewModel source = mediaSource, audioStream = audioStream, seriesId = base.seriesId, - itemPlayback = playbackConfig, + itemPlayback = itemPlayback, plc = plc, prefs = preferences, )?.index @@ -551,7 +545,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, @@ -821,6 +815,7 @@ class PlaybackViewModel subtitleIndex, source, ) + Timber.v("onTracksChanged: %s", result) if (result.bothSelected) { player.trackSelectionParameters = result.trackSelectionParameters 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) } From 8107a82a6b3ef220ef79ab39145df68bc71a5c45 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 6 May 2026 11:49:27 -0400 Subject: [PATCH 81/92] Fixes/improves showing delete button (#1353) ## Description Fixes the delete button not showing on the movie detail page. Also fixes some edge cases where button may not appear when toggling on the media management setting. Adds the delete button to album & artist pages. Previously, it was only in the context menu. ### Related issues Fixes #1343 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/MediaManagementService.kt | 13 +++++++++++++ .../ui/detail/collection/CollectionDetails.kt | 5 ++++- .../ui/detail/episode/EpisodeViewModel.kt | 14 +++----------- .../wholphin/ui/detail/movie/MovieViewModel.kt | 15 +++------------ .../wholphin/ui/detail/music/AlbumDetailsPage.kt | 12 ++++++++++++ .../wholphin/ui/detail/music/ArtistDetailsPage.kt | 11 +++++++++++ .../ui/detail/music/MusicExpandableButtons.kt | 13 +++++++++++++ .../wholphin/ui/detail/series/SeriesViewModel.kt | 7 ++++++- 8 files changed, 65 insertions(+), 25 deletions(-) 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/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt index 332e2a3f..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 @@ -207,7 +207,10 @@ fun CollectionDetails( } }, canDelete = - remember(state.collection) { + remember( + state.collection, + preferences.appPreferences.interfacePreferences.enableMediaManagement, + ) { state.collection?.let { viewModel.canDelete(it, preferences.appPreferences) } ?: false 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/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 173ba49b..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 @@ -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) @@ -389,6 +396,7 @@ fun AlbumDetailsPage( modifier = Modifier.fillMaxWidth(), ) MusicExpandableButtons( + title = album.title ?: "", actions = remember { MusicButtonActions( @@ -415,9 +423,13 @@ fun AlbumDetailsPage( actions = moreDialogActions, ) }, + onConfirmDelete = { + viewModel.deleteItem(album) + }, ) }, favorite = album.favorite, + canDelete = state.canDelete, buttonOnFocusChanged = { if (it.isFocused) { position = RowColumn(HEADER_ROW, 0) 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 77f2e063..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 @@ -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, ) } } @@ -378,6 +386,7 @@ fun ArtistDetailsPage( modifier = Modifier.fillMaxWidth(), ) MusicExpandableButtons( + title = artist.title ?: "", actions = remember { MusicButtonActions( @@ -406,9 +415,11 @@ fun ArtistDetailsPage( actions = moreDialogActions, ) }, + onConfirmDelete = { viewModel.deleteItem(artist) }, ) }, favorite = artist.favorite, + canDelete = state.canDelete, buttonOnFocusChanged = { if (it.isFocused) { position = RowColumn(HEADER_ROW, 0) 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/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index c84f9b8d..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) From 45ab0713a945498bf4b6367e70891b9623b74610 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 6 May 2026 18:08:29 -0400 Subject: [PATCH 82/92] Update AGP, Gradle, & other third party dependencies (#1356) ## Description Just a lot of dependency bumps. There are no user facing changes. Switches to the new Android gradle DSL now that the protobuf plugin is compatible. ### Related issues None ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- app/build.gradle.kts | 55 +++++++++++++---------- gradle.properties | 2 +- gradle/libs.versions.toml | 18 ++++---- gradle/wrapper/gradle-wrapper.jar | Bin 48966 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew | 2 +- gradlew.bat | 31 +++++-------- 7 files changed, 56 insertions(+), 56 deletions(-) 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/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 3712c45a..ef483c5b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,18 @@ [versions] -aboutLibraries = "14.0.1" +aboutLibraries = "14.1.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" +kotlin = "2.3.21" ksp = "2.3.7" coreKtx = "1.18.0" appcompat = "1.7.1" -composeBom = "2026.04.01" +composeBom = "2026.05.00" mockk = "1.14.9" robolectric = "4.16.1" multiplatformMarkdownRenderer = "0.40.2" @@ -20,8 +20,8 @@ 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" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" androidx-media3 = "1.10.0" @@ -30,7 +30,7 @@ jellyfin-sdk = "1.7.1" 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" @@ -43,9 +43,9 @@ paletteKtx = "1.0.0" assMedia = "0.4.0" kotlinxCoroutinesTest = "1.10.2" 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 d997cfc60f4cff0e7451d19d49a82fa986695d07..b1b8ef56b44f16b14dc800fa8103a6d89abb526f 100644 GIT binary patch delta 39760 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 e(*aw}>J`&Ep$5$o2mVP)i30-M3~u@DBh0 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 delta 40115 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 e{Sg5G5zENPNVU~b5#<2@M#Os+0czL&{q{eZe&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% From b23d30f94b2d607a1b9ae4f60087fee6d929da1f Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 6 May 2026 18:29:26 -0400 Subject: [PATCH 83/92] Slightly optimize subtitles view & workaround possible crash (#1334) ## Description Instead of adding/removing the `SubtitleView` from composition sometimes, instead toggle its visibility. This is technically slightly more efficient. There's also a couple other minor tweaks that make the subtitles style a little more efficient. This should also help prevent crash caused by possible a race condition in libass-android (https://github.com/peerless2012/libass-android/issues/75). ### Related issues I think this fixes #1322 ### Testing Emulator mostly ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/playback/PlaybackPage.kt | 122 ++++++++++-------- 1 file changed, 66 insertions(+), 56 deletions(-) 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 68dcf9c5..d3c85825 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,7 +76,6 @@ 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 @@ -435,65 +436,74 @@ 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) } + 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 -> + val subtitleVisible = + skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface + subtitleView.visibility = if (subtitleVisible) View.VISIBLE else View.INVISIBLE + + 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) { + 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 (isImageSubtitles) subtitleImageOpacity else 1f + }, + ) } // Ask to skip intros, etc button From dbd17597b2c024112ed094e84aab4398bda0162f Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 10 May 2026 08:50:08 -0400 Subject: [PATCH 84/92] Show public users when switching users (#1358) ## Description Show the public users (ie "Hide this user from login screens" is unchecked) if the server has any. Previously, all users had to be explicitly added by username or quick connect. This PR also refactors the server & user pages to use modern state flows and deletes some unused code. ### Related issues Closes #600 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainContent.kt | 2 +- .../wholphin/data/ServerRepository.kt | 7 +- .../wholphin/ui/setup/ServerList.kt | 127 ----- .../wholphin/ui/setup/SwitchServerContent.kt | 79 ++- .../ui/setup/SwitchServerViewModel.kt | 216 ++++---- .../wholphin/ui/setup/SwitchUserContent.kt | 483 ++++++++++-------- .../wholphin/ui/setup/SwitchUserViewModel.kt | 231 +++++---- .../damontecres/wholphin/ui/setup/UserList.kt | 44 +- .../damontecres/wholphin/ui/BasicUiTests.kt | 17 +- 9 files changed, 579 insertions(+), 627 deletions(-) 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/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/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/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() } From df71baccea31a0c89fbac0732500f5c71e043d15 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 10 May 2026 18:00:09 -0400 Subject: [PATCH 85/92] Fix remove series from next up (#1369) ## Description Fixes the "Remove series from next up" context menu item not working at all. Looks like after the refactoring in #1302, I missed [adding back the function call](https://github.com/damontecres/Wholphin/pull/1302/changes#diff-440f67bfe7be2011418c2f90e3a8ae2af05795a5161edd1a8bda14bc3b962cdcL175). ### Related issues Fixes #1363 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../java/com/github/damontecres/wholphin/ui/main/HomePage.kt | 1 + 1 file changed, 1 insertion(+) 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 47a86748..1e58a1d6 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 @@ -182,6 +182,7 @@ fun HomePage( overviewDialog = ItemDetailsDialogInfo(it) }, onClearChosenStreams = {}, + onClickRemoveFromNextUp = viewModel::removeFromNextUp, ), ) } From cb46aa41592201df9094b2f8000165ca5978d0e9 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 11 May 2026 10:22:27 -0400 Subject: [PATCH 86/92] Rearrange playback controller buttons (#1366) ## Description - Moves the audio track selection out of the menu as a button - Combines the settings & more buttons into one dialog All functionality works the same, this is just UI changes ### Related issues Related to #528 ### Testing Emulator, it's just UI changes ## Screenshots ### Before controller_before Large ### After controller_update Large ## AI or LLM usage None --- .../wholphin/ui/playback/PlaybackDialog.kt | 66 +++++-------------- .../wholphin/ui/playback/PlaybackPage.kt | 10 ++- .../ui/playback/overlay/PlaybackController.kt | 8 --- .../ui/playback/overlay/PlaybackControls.kt | 32 ++++----- 4 files changed, 38 insertions(+), 78 deletions(-) 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 8217a516..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 @@ -43,7 +43,7 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import kotlin.time.Duration enum class PlaybackDialogType { - MORE, + DEBUG, CAPTIONS, SETTINGS, AUDIO, @@ -86,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 -> { @@ -138,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, @@ -177,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, ) } @@ -196,10 +178,6 @@ fun PlaybackDialog( onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() -// scope.launch { -// delay(250L) -// settingsFocusRequester.tryRequestFocus() -// } }, onSelectChoice = { _, choice -> onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index)) @@ -223,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, ) } @@ -250,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/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index d3c85825..e0e24f4c 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 @@ -660,7 +660,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/overlay/PlaybackController.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt index 04f3d80a..0de48d15 100644 --- 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 @@ -23,7 +23,6 @@ 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.onFocusChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -225,10 +224,6 @@ fun Controller( ) } } - // TODO need to move these up a level? - val moreFocusRequester = remember { FocusRequester() } - val captionFocusRequester = remember { FocusRequester() } - val settingsFocusRequester = remember { FocusRequester() } PlaybackControls( modifier = Modifier.fillMaxWidth(), player = player, @@ -248,9 +243,6 @@ fun Controller( 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/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt index ae037e38..f423b8ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt @@ -129,9 +129,6 @@ fun PlaybackControls( controllerViewState: ControllerViewState, onPlaybackActionClick: (PlaybackAction) -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, - moreFocusRequester: FocusRequester, - captionFocusRequester: FocusRequester, - settingsFocusRequester: FocusRequester, onSeekProgress: (Long) -> Unit, showPlay: Boolean, previousEnabled: Boolean, @@ -186,7 +183,6 @@ fun PlaybackControls( .fillMaxWidth(), ) { LeftPlaybackButtons( - moreFocusRequester = moreFocusRequester, onControllerInteraction = onControllerInteraction, onClickPlaybackDialogType = onClickPlaybackDialogType, modifier = Modifier.align(Alignment.CenterStart), @@ -220,8 +216,6 @@ fun PlaybackControls( ) } RightPlaybackButtons( - captionFocusRequester = captionFocusRequester, - settingsFocusRequester = settingsFocusRequester, onControllerInteraction = onControllerInteraction, onClickPlaybackDialogType = onClickPlaybackDialogType, modifier = Modifier, @@ -303,7 +297,6 @@ val buttonSpacing = 12.dp @Composable fun LeftPlaybackButtons( - moreFocusRequester: FocusRequester, onControllerInteraction: () -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, modifier: Modifier = Modifier, @@ -312,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, @@ -347,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, ) } } @@ -625,7 +615,7 @@ private fun ButtonPreview() { onControllerInteraction = {}, ) PlaybackFaButton( - iconRes = R.string.fa_shuffle, + iconRes = R.string.fa_volume_high, onClick = {}, onControllerInteraction = {}, textColor = Color.Green, From 08db2b0efc81b10fa9ed1416ea6c35dc8f2d6e79 Mon Sep 17 00:00:00 2001 From: Brian Gunter Date: Mon, 11 May 2026 10:23:02 -0400 Subject: [PATCH 87/92] Accept digits in the PIN field in addition to l/r/u/d (#1330) ## Description This enhances the PIN entry system by allowing users to input digits using the numbers 0-9. This is useful for those using a keyboard or universal remote with a number pad. ### Testing Tested on Nvidia Shield Pro and Onn 4k Pro ## Screenshots Screenshot_20260429_083041 --------- Co-authored-by: Damontecres --- .../damontecres/wholphin/ui/setup/PinEntry.kt | 28 +++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 2 files changed, 29 insertions(+) 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index afa848d6..b9b56865 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 From 59af13d29e53cdfc3c827d34c97a05a02abe628a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 10:26:43 -0400 Subject: [PATCH 88/92] Update Dependencies to v14.2.0 (#1335) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef483c5b..693cdf8e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -aboutLibraries = "14.1.0" +aboutLibraries = "14.2.0" acra = "5.13.1" agp = "9.1.1" auto-service = "1.1.1" From 9ddce95e5773560b69bd69a1defe1c5619e1ab48 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 10:27:07 -0400 Subject: [PATCH 89/92] Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-test to v1.11.0 (#1299) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 693cdf8e..09f1cae8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ 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.22.0" runner = "1.7.0" From 4928c1156d901755698a3eb56e56e9772b2af898 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 11 May 2026 11:12:05 -0400 Subject: [PATCH 90/92] Fix cleaning up APKs on debug builds (#1373) ## Description Directly use "Wholphin" when searching for older APKs to clean up. On debug builds, the `app_name` string is "Wholphin (Debug)" but the APK file names is always "Wholphin.apk", so I think the debug build won't clean them up. ### Related issues Fixes #1368 ### Testing Testing downloading a few times without this change, applied the change, and clean up worked ## Screenshots N/A ## AI or LLM usage None --- .../com/github/damontecres/wholphin/services/UpdateChecker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 { From 8b9b0ddec842757057649e1c5e55337b9cb90f69 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 11 May 2026 17:48:38 -0400 Subject: [PATCH 91/92] Fix two home row focus bugs (#1376) ## Description 1. Fixes when moving from nav drawer to home page content, focus would sometimes move up a row 2. Fixes when moving up/down between rows, focus would sometimes move to the later card (usually the last visible) The second above is fixed by downgrading `tv-material` to `1.0.1-alpha01`. Switching to [`1.0.1-beta01`](https://developer.android.com/jetpack/androidx/releases/tv#tv-material-1.1.0-beta01) or newer causes the bug. Not clear if its a bug in the library or Wholphin though. ### Related issues Fixes #1359 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../github/damontecres/wholphin/ui/main/HomePage.kt | 11 ++++++++++- gradle/libs.versions.toml | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) 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 1e58a1d6..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,6 +31,7 @@ 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 @@ -307,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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09f1cae8..eab5a055 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" tvFoundation = "1.0.0" -tvMaterial = "1.1.0" +tvMaterial = "1.1.0-alpha01" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" androidx-media3 = "1.10.0" From 96f94feac26edfc269cb8101e3d2511617e5a55f Mon Sep 17 00:00:00 2001 From: dudecuda <94335388+dudecuda@users.noreply.github.com> Date: Mon, 11 May 2026 17:49:08 -0400 Subject: [PATCH 92/92] libass-android lifecycle issue: Fixes #1049 (much of it) (#1362) Fixed a bug with libass integration where the background thread responsible for drawing the ASS subtitles (AssRenderThread) has been shut down, but ExoPlayer is still actively running and trying to push video time updates to it. Added fallback for software rendering on older android versions that can have broken OpenGl implementation.This should address much of issue #1049. ## Description Essentially, debug logs showed repeated: 3145 3920 W MessageQueue: Handler (android.os.Handler) {4ba3c72} sending message to a Handler on a dead thread 3145 3920 W MessageQueue: java.lang.IllegalStateException: Handler (android.os.Handler) {4ba3c72} sending message to a Handler on a dead thread Essentially this was a lifecycle bug where the libass-android thread was created, but was detaching from the window, shutting down when it does so. It was still registered to ExoPlayer, but since there was no thread to send to, it just repeatedly kept failing, never spinning back up to display any subtitles. It seems just about any ui change can cause this detachment, but in this case it seems it was right from the moment the loading spinner shows up (making libass-android fail before video starts playing). Fixed by ensuring TextureView's visibility wasn't changed to invisible, which ends up destroying the canvas that libass-android attaches itself to. Hid the canvas using graphicsLayer (so the user can't see it, but it's still alive and active). Also changed resizing logic to prevent Android from seeing a 0x0 TextureView as "destroyed". Since I discovered in testing how terribly my TV's OpenGl was handling this, I added an additional fallback for Android versions < 9. Older TV's can fallback to software rendering when using libass-android. ### Related issues #### Issue #1049 : **Fixes:** - the issue where libass-android (when enabled) fails to load any subtitles in ExoPlayer DirectPlay. **Not Fixed from issue #1049 :** - Issues around accented characters. - Any issues surrounding MPV (I have no way of testing, MPV never worked on any player for me) - Fallback mode (libass-android disabled) where subtitles aren't rendering properly **Added:** - While testing, discovered older Android versions have trouble with reliable OpenGl rendering. Thus I added a fallback for older Android versions to use libass-android in software rendering mode (AssRenderType.OVERLAY_CANVAS) ### Testing - Tested and confirmed flawlessly to work on Android 9 TCL Smart TV (AssRenderType.OVERLAY_CANVAS) - Tested on Android 14 Chromecast (AssRenderType.OVERLAY_OPEN_GL). Slightly less perfect (font not always a match, less fancy shadows) but most ASS/SSA effects work well. May be worth testing other AssRenderType options to compare features and performance on other machines. ## AI or LLM usage Google Gemini Pro in tracing down the cause and fine-tuning the fixes. --------- Co-authored-by: Damontecres --- .../wholphin/services/PlayerFactory.kt | 8 +++++++- .../wholphin/ui/playback/PlaybackPage.kt | 19 +++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) 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/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index e0e24f4c..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 @@ -439,6 +439,9 @@ fun PlaybackPageContent( 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 { @@ -465,10 +468,6 @@ fun PlaybackPageContent( } }, update = { subtitleView -> - val subtitleVisible = - skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface - subtitleView.visibility = if (subtitleVisible) View.VISIBLE else View.INVISIBLE - 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 @@ -480,7 +479,8 @@ fun PlaybackPageContent( (it as? AssSubtitleView)?.apply { val resized = layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height } - if (resized) { + + if (resized && playerSurfaceSize.width > 0 && playerSurfaceSize.height > 0) { Timber.v("Resizing AssSubtitleView: %s", playerSurfaceSize) layoutParams = FrameLayout @@ -501,7 +501,14 @@ fun PlaybackPageContent( .align(Alignment.TopCenter) .background(Color.Transparent) .graphicsLayer { - alpha = if (isImageSubtitles) subtitleImageOpacity else 1f + alpha = + if (!subtitleVisible) { + 0f + } else if (isImageSubtitles) { + subtitleImageOpacity + } else { + 1f + } }, ) }