diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index eae1ff9c..3043d994 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,12 +1,17 @@ + + ## Description ### Related issues - + -### Screenshots +### Testing + + +## Screenshots -### AI/LLM usage - +## AI or LLM usage + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c8484fa..3eee4ee9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,3 +23,10 @@ You acknowledge that, if applicable, submitting and subsequent acceptance of any Please be thoughtful when contributing code and consider the long term maintenance/support aspect for any new features. See the [developer's guide](./DEVELOPMENT.md) for more information. + + +#### Policy for AI or LLMs + +Wholphin has adopted the [Jellyfin LLM/"AI" Development Policy](https://jellyfin.org/docs/general/contributing/llm-policies/). AI/LLM assisted code contributions are permitted under the policy, but the author must undertand and be able to explain the code changes in their own words. + +At a minimum, you must disclose the usage any of AI/LLM assistance and you must write the pull request description _without_ AI assistance. diff --git a/README.md b/README.md index 45c96f5a..b4121cb9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Wholphin is an open-source Android TV client for Jellyfin. It aims to provide a different app UI that's inspired by Plex for users interested in migrating to Jellyfin. -This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin `v0.3.0+` supports playing media using either ExoPlayer/Media3 or MPV (experimental). +This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin supports playing media using either ExoPlayer or MPV.

@@ -31,29 +31,31 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin ### User interface - A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app +- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows - Option to combine Continue Watching & Next Up rows - Show Movie/TV Show titles when browsing libraries - Play theme music, if available +- Customize subtitle style for plain text subtitles - Search & download subtitles (requires compatible server plugin such as [OpenSubtitles](https://github.com/jellyfin/jellyfin-plugin-opensubtitles)) - Customize layout grids for libraries - Multiple app color themes +- Protect user profile switches with PIN code ### Playback -- Different media playback engines, including: - - Default ExoPlayer/Media3 - - Experimental MPV -- Plex inspired playback controls, such as: +- Different media playback engines: + - **ExoPlayer** w/ optional extra audio & AV1 software decoding + - **MPV** for direct playing anything plus ASS subtitle support +- Plex inspired playback controls: - Using D-Pad left/right for seeking during playback - Quickly access video chapters & queue during playback - Optionally skip back a few seconds when resuming playback - Live TV & DVR support - Auto play next episodes with pass out protection -- Option for automatic refresh rate switching on supported displays +- Option for automatic refresh rate & resolution switching on supported displays - Trickplay support -- Other (subjective) enhancements: - - Subtly show playback position along the bottom of the screen while seeking w/ D-Pad - - Force Continue Watching & Next Up TV episodes to use their Series posters +- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad + ### Roadmap @@ -61,6 +63,8 @@ See [here for the roadmap](https://github.com/damontecres/Wholphin/wiki#roadmap) ## Installation +Using [Google Play](https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin) or [Amazon appstore](https://www.amazon.com/gp/product/B0G8RQQR9T/ref=mas_pm_wholphin) are the fastest way to install. But you can follow these instructions to install without needing an app store + Downloader Code: `8668671` 1. Enable side-loading "unknown" apps @@ -68,7 +72,7 @@ Downloader Code: `8668671` - https://www.xda-developers.com/how-to-sideload-apps-android-tv/ - https://developer.android.com/distribute/marketing-tools/alternative-distribution#unknown-sources - https://www.aftvnews.com/how-to-enable-apps-from-unknown-sources-on-an-amazon-fire-tv-or-fire-tv-stick/ -1. Install the APK on your Android TV device with one of these options: +2. Install the APK on your Android TV device with one of these options: - Install a browser program such as [Downloader](https://www.aftvnews.com/downloader/), use it to get the latest apk with short code `8668671` or URL: http://aftv.news/8668671 - Download the latest APK release from the [releases page](https://github.com/damontecres/Wholphin/releases/latest) or http://aftv.news/8668671 - Put the APK on an SD Card/USB stick/network share and use a file manager app from the Google Play Store / Amazon AppStore (e.g. `FX File Explorer`). Android's preinstalled file manager probably will not work! @@ -81,12 +85,16 @@ After the initial install above, the app will automatically check for updates. T The first time you attempt an update, the OS should guide you through enabling the required additional permissions for the app to install updates. +Note: if installed via an app store, the app store will handle updates. + ## Compatibility -Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x` (tested on primarily `10.11.3`). +Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x` (tested on primarily `10.11`). The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue! +Jellyseerr integration is tested with `v2.7.3`. Older versions may not work. + ## Contributions Issues and pull requests are always welcome! Please check before submitting that your issue or pull request is not a duplicate. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ccc0f74e..161352af 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + xmlns:tools="http://schemas.android.com/tools" + android:installLocation="auto"> 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 fb3e88ed..0f8b7ee4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin import android.content.Intent import android.content.res.Configuration import android.os.Bundle +import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -138,8 +139,17 @@ class MainActivity : AppCompatActivity() { window.attributes = attrs.apply { preferredDisplayModeId = modeId } } } + viewModel.serverRepository.currentUser.observe(this) { user -> + if (user?.hasPin == true) { + window?.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } else { + window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } viewModel.appStart() - val requestedDestination = this.intent?.let(::extractDestination) setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) appPreferences?.let { appPreferences -> @@ -246,6 +256,10 @@ class MainActivity : AppCompatActivity() { } if (showContent) { + val requestedDestination = + remember(intent) { + intent?.let(::extractDestination) + } ApplicationContent( user = current.user, server = current.server, @@ -292,14 +306,6 @@ class MainActivity : AppCompatActivity() { super.onRestart() Timber.d("onRestart") viewModel.appStart() -// val signInAutomatically = -// runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true - -// // TODO PIN-related -// // if (!signInAutomatically || serverRepository.currentUser.value?.hasPin == true) { -// if (!signInAutomatically) { -// serverRepository.closeSession() -// } } override fun onStop() { @@ -341,6 +347,7 @@ class MainActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) Timber.v("onNewIntent") + setIntent(intent) extractDestination(intent)?.let { navigationManager.replace(it) } @@ -392,7 +399,7 @@ class MainActivityViewModel @Inject constructor( private val preferences: DataStore, - private val serverRepository: ServerRepository, + val serverRepository: ServerRepository, private val navigationManager: SetupNavigationManager, private val deviceProfileService: DeviceProfileService, private val backdropService: BackdropService, @@ -402,15 +409,20 @@ class MainActivityViewModel try { val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - if (prefs.signInAutomatically) { + val userHasPin = serverRepository.currentUser.value?.hasPin == true + if (prefs.signInAutomatically && !userHasPin) { val current = serverRepository.restoreSession( prefs.currentServerId?.toUUIDOrNull(), prefs.currentUserId?.toUUIDOrNull(), ) if (current != null) { - // Restored - navigationManager.navigateTo(SetupDestination.AppContent(current)) + if (current.user.hasPin) { + navigationManager.navigateTo(SetupDestination.UserList(current.server)) + } else { + // Restored + navigationManager.navigateTo(SetupDestination.AppContent(current)) + } } else { // Did not restore navigationManager.navigateTo(SetupDestination.ServerList) diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index ae4b5d28..cf2c159a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.ExperimentalComposeRuntimeApi import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp +import okhttp3.OkHttp import org.acra.ACRA import org.acra.ReportField import org.acra.config.dialog @@ -63,6 +64,7 @@ class WholphinApplication : override fun onCreate() { super.onCreate() + OkHttp.initialize(this) initAcra { buildConfigClass = BuildConfig::class.java reportFormat = StringFormat.JSON 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 1e5cade5..f66dcfca 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 @@ -142,8 +142,6 @@ class ServerRepository } else { val user = serverAndUsers.users.firstOrNull { it.id == userId } if (user != null) { - // TODO pin-related -// if (user != null && !user.hasPin) { return changeUser(serverAndUsers.server, user) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt index 92dbecf3..fec0fb8d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt @@ -39,6 +39,17 @@ val DefaultForGenresFilterOptions = DecadeFilter, ) +val DefaultPlaylistItemsOptions = + listOf( + PlayedFilter, + FavoriteFilter, + CommunityRatingFilter, + OfficialRatingFilter, + VideoTypeFilter, + YearFilter, + DecadeFilter, + ) + sealed interface ItemFilterBy { @get:StringRes val stringRes: Int diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index d1893dbb..a9673131 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.ui.DateFormatter +import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.dot @@ -71,8 +72,7 @@ data class BaseItem( @Transient val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } - @Transient - val indexNumber = data.indexNumber ?: dateAsIndex() + val indexNumber get() = data.indexNumber val playbackPosition get() = data.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO @@ -88,6 +88,15 @@ data class BaseItem( @Transient val ui = BaseItemUi( + episodeCornerText = + data.indexNumber?.let { "E$it" } + ?: data.premiereDate?.let(::formatDateTime), + episdodeUnplayedCornerText = + data.indexNumber?.let { "E$it" } + ?: data.userData + ?.unplayedItemCount + ?.takeIf { it > 0 } + ?.let { abbreviateNumber(it) }, quickDetails = buildAnnotatedString { val details = @@ -191,5 +200,7 @@ val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { @Immutable data class BaseItemUi( + val episodeCornerText: String?, + val episdodeUnplayedCornerText: String?, val quickDetails: AnnotatedString, ) 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 f7f7ba25..70e83692 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 @@ -752,10 +752,23 @@ sealed interface AppPreference { prefs.updatePlaybackPreferences { playerBackend = value } }, displayValues = R.array.player_backend_options, + subtitles = R.array.player_backend_options_subtitles, indexToValue = { PlayerBackend.forNumber(it) }, valueToIndex = { it.number }, ) + val ExoPlayerSettings = + AppDestinationPreference( + title = R.string.exoplayer_options, + destination = Destination.Settings(PreferenceScreenOption.EXO_PLAYER), + ) + + val MpvSettings = + AppDestinationPreference( + title = R.string.mpv_options, + destination = Destination.Settings(PreferenceScreenOption.MPV), + ) + val MpvHardwareDecoding = AppSwitchPreference( title = R.string.mpv_hardware_decoding, @@ -770,7 +783,7 @@ sealed interface AppPreference { val MpvGpuNext = AppSwitchPreference( title = R.string.mpv_use_gpu_next, - defaultValue = true, + defaultValue = false, getter = { it.playbackPreferences.mpvOptions.useGpuNext }, setter = { prefs, value -> prefs.updateMpvOptions { useGpuNext = value } @@ -932,8 +945,7 @@ val basicPreferences = title = R.string.profile_specific_settings, preferences = listOf( - // TODO PIN-related - // AppPreference.RequireProfilePin, + AppPreference.RequireProfilePin, AppPreference.UserPinnedNavDrawerItems, ), ), @@ -959,6 +971,40 @@ val basicPreferences = val uiPreferences = listOf() +private val ExoPlayerSettings = + listOf( + AppPreference.FfmpegPreference, + AppPreference.DownMixStereo, + AppPreference.Ac3Supported, + AppPreference.DirectPlayAss, + AppPreference.DirectPlayPgs, + AppPreference.DirectPlayDoviProfile7, + AppPreference.DecodeAv1, + ) + +val ExoPlayerPreferences = + listOf( + PreferenceGroup( + title = R.string.exoplayer_options, + preferences = ExoPlayerSettings, + ), + ) + +private val MpvSettings = + listOf( + AppPreference.MpvHardwareDecoding, + AppPreference.MpvGpuNext, + AppPreference.MpvConfFile, + ) + +val MpvPreferences = + listOf( + PreferenceGroup( + title = R.string.mpv_options, + preferences = MpvSettings, + ), + ) + val advancedPreferences = buildList { add( @@ -1009,22 +1055,17 @@ val advancedPreferences = listOf( ConditionalPreferences( { it.playbackPreferences.playerBackend == PlayerBackend.EXO_PLAYER }, - listOf( - AppPreference.FfmpegPreference, - AppPreference.DownMixStereo, - AppPreference.Ac3Supported, - AppPreference.DirectPlayAss, - AppPreference.DirectPlayPgs, - AppPreference.DirectPlayDoviProfile7, - AppPreference.DecodeAv1, - ), + ExoPlayerSettings, ), ConditionalPreferences( { it.playbackPreferences.playerBackend == PlayerBackend.MPV }, + MpvSettings, + ), + ConditionalPreferences( + { it.playbackPreferences.playerBackend == PlayerBackend.PREFER_MPV }, listOf( - AppPreference.MpvHardwareDecoding, - AppPreference.MpvGpuNext, - AppPreference.MpvConfFile, + AppPreference.ExoPlayerSettings, + AppPreference.MpvSettings, ), ), ), @@ -1109,6 +1150,7 @@ data class AppChoicePreference( override val getter: (prefs: Pref) -> T, override val setter: (prefs: Pref, value: T) -> Pref, @param:StringRes val summary: Int? = null, + @param:ArrayRes val subtitles: Int? = null, ) : AppPreference data class AppMultiChoicePreference( 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 08dc0d77..d6fbea40 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 @@ -197,4 +197,20 @@ suspend fun upgradeApp( } } } + + // TODO temporarily disabled until some MPV bugs are fixed +// if (previous.isEqualOrBefore(Version.fromString("0.4.0-1-g0"))) { +// appPreferences.updateData { +// it.updatePlaybackPreferences { playerBackend = PlayerBackend.PREFER_MPV } +// } +// showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG) +// } + + if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) { + appPreferences.updateData { + it.updateMpvOptions { + useGpuNext = false + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 63a85cef..a1754298 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -75,7 +75,11 @@ class NavigationManager while (backStack.size > 1) { backStack.removeLastOrNull() } - backStack[0] = destination + if (backStack.isEmpty()) { + backStack.add(0, destination) + } else { + backStack[0] = destination + } log() } 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 f765ba86..225c4b69 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 @@ -23,6 +23,7 @@ import androidx.media3.extractor.DefaultExtractorsFactory import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.MediaExtensionStatus +import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.util.mpv.MpvPlayer import dagger.hilt.android.qualifiers.ApplicationContext @@ -31,8 +32,8 @@ import io.github.peerless2012.ass.media.factory.AssRenderersFactory import io.github.peerless2012.ass.media.kt.withAssMkvSupport import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory import io.github.peerless2012.ass.media.type.AssRenderType -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber import java.lang.reflect.Constructor import javax.inject.Inject @@ -52,27 +53,25 @@ class PlayerFactory var currentPlayer: Player? = null private set - fun createVideoPlayer(): PlayerCreation { - if (currentPlayer?.isReleased == false) { - Timber.w("Player was not released before trying to create a new one!") - currentPlayer?.release() + suspend fun createVideoPlayer( + backend: PlayerBackend, + prefs: PlaybackPreferences, + ): PlayerCreation { + withContext(Dispatchers.Main) { + if (currentPlayer?.isReleased == false) { + Timber.w("Player was not released before trying to create a new one!") + currentPlayer?.release() + } } var assHandler: AssHandler? = null - val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences } - val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue val newPlayer = when (backend) { - PlayerBackend.MPV -> { - val enableHardwareDecoding = - prefs?.mpvOptions?.enableHardwareDecoding - ?: AppPreference.MpvHardwareDecoding.defaultValue - val useGpuNext = - prefs?.mpvOptions?.useGpuNext - ?: AppPreference.MpvGpuNext.defaultValue + PlayerBackend.PREFER_MPV, + PlayerBackend.MPV, + -> { + val enableHardwareDecoding = prefs.mpvOptions.enableHardwareDecoding + val useGpuNext = prefs.mpvOptions.useGpuNext MpvPlayer(context, enableHardwareDecoding, useGpuNext) - .apply { - playWhenReady = true - } } PlayerBackend.EXO_PLAYER, @@ -122,7 +121,6 @@ class PlayerFactory .build() .apply { assHandler?.init(this) - playWhenReady = true } } } 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 824342d5..4231d9b9 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 @@ -17,7 +17,6 @@ import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler import com.github.damontecres.wholphin.util.TransformList import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -34,7 +33,6 @@ import org.jellyfin.sdk.model.api.PlaylistUserPermissions import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import javax.inject.Inject @@ -79,19 +77,22 @@ class PlaylistCreator suspend fun createFromPlaylistId( playlistId: UUID, startIndex: Int?, - shuffled: Boolean, + sortAndDirection: SortAndDirection, + filter: GetItemsFilter, ): Playlist { val request = - GetPlaylistItemsRequest( - playlistId = playlistId, - fields = DefaultItemFields, - startIndex = startIndex, - limit = Playlist.MAX_SIZE, + filter.applyTo( + GetItemsRequest( + userId = serverRepository.currentUser.value?.id, + parentId = playlistId, + fields = DefaultItemFields, + startIndex = startIndex, + limit = Playlist.MAX_SIZE, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), ) - var items = GetPlaylistItemsRequestHandler.execute(api, request).content.items - if (shuffled) { - items = items.shuffled() - } + val items = GetItemsRequestHandler.execute(api, request).content.items return Playlist(items.convertAndAddParts(), 0) } @@ -206,9 +207,18 @@ class PlaylistCreator BaseItemKind.PLAYLIST -> { PlaylistCreationResult.Success( createFromPlaylistId( - item.id, - startIndex, - shuffled, + playlistId = item.id, + startIndex = startIndex, + sortAndDirection = + if (shuffled) { + SortAndDirection(ItemSortBy.RANDOM, SortOrder.ASCENDING) + } else { + sortAndDirection ?: SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ) + }, + filter = filter, ), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index e267a12f..f06a7529 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -30,6 +30,7 @@ import okhttp3.OkHttpClient import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds /** * Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed. @@ -137,7 +138,17 @@ class SeerrServerRepository username: String?, passwordOrApiKey: String, ): LoadingState { - val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient) + val apiKey = passwordOrApiKey.takeIf { authMethod == SeerrAuthMethod.API_KEY } + val api = + SeerrApiClient( + url, + apiKey, + okHttpClient + .newBuilder() + .connectTimeout(5.seconds) + .readTimeout(6.seconds) + .build(), + ) login(api, authMethod, username, passwordOrApiKey) return LoadingState.Success } @@ -185,6 +196,7 @@ private suspend fun login( password = password ?: "", ), ) + client.usersApi.authMeGet() } SeerrAuthMethod.JELLYFIN -> { @@ -194,6 +206,7 @@ private suspend fun login( password = password ?: "", ), ) + client.usersApi.authMeGet() } SeerrAuthMethod.API_KEY -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index e38957cf..6de45f50 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -276,10 +276,16 @@ class StreamChoiceService SubtitlePlaybackMode.SMART -> { if (subtitleLanguage.isNotNullOrBlank()) { val audioLanguage = userConfig?.audioLanguagePreference - if (audioLanguage.isNullOrBlank() || audioLanguage != audioStreamLang) { + if ( + // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different + (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) || + // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio + (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang) + ) { candidates.firstOrNull { it.language == subtitleLanguage } ?: candidates.firstOrNull { it.language.isUnknown } } else { + // Otherwise, show forced subtitles in preferred lang candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index 4dbb0870..12854cd7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -56,7 +56,6 @@ import kotlin.contracts.contract import kotlin.coroutines.CoroutineContext import kotlin.math.min import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -234,7 +233,7 @@ val Duration.roundMinutes: Duration * Rounds a [Duration] to nearest whole second */ val Duration.roundSeconds: Duration - get() = (this + 30.milliseconds).inWholeSeconds.seconds + get() = (this + .5.seconds).inWholeSeconds.seconds /** * Gets the user's playback position as a [Duration] diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index ac0bbece..70de419b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults @@ -40,7 +41,6 @@ import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.model.api.ImageType /** @@ -63,17 +63,19 @@ fun BannerCard( ) { val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current + val fillHeight = + remember(cardHeight) { + if (cardHeight.isSpecified) { + with(density) { + cardHeight.roundToPx() + } + } else { + null + } + } val imageUrl = - remember(item, cardHeight) { + remember(item, fillHeight) { if (item != null) { - val fillHeight = - if (cardHeight != Dp.Unspecified) { - with(density) { - cardHeight.roundToPx() - } - } else { - null - } imageUrlService.getItemImageUrl( item, ImageType.PRIMARY, @@ -101,7 +103,7 @@ fun BannerCard( .fillMaxSize(), // .background(MaterialTheme.colorScheme.surfaceVariant), ) { - if (!imageError && imageUrl.isNotNullOrBlank()) { + if (!imageError && imageUrl != null) { AsyncImage( model = imageUrl, contentDescription = null, @@ -121,7 +123,7 @@ fun BannerCard( .align(Alignment.Center), ) } - if (played || cornerText.isNotNullOrBlank()) { + if (played || cornerText != null) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, @@ -133,7 +135,7 @@ fun BannerCard( if (played && (playPercent <= 0 || playPercent >= 100)) { WatchedIcon(Modifier.size(24.dp)) } - if (cornerText.isNotNullOrBlank()) { + if (cornerText != null) { Box( modifier = Modifier 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 3fa8ffa5..29991126 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 @@ -47,18 +47,9 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter -import com.github.damontecres.wholphin.data.filter.DecadeFilter import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions -import com.github.damontecres.wholphin.data.filter.FavoriteFilter import com.github.damontecres.wholphin.data.filter.FilterValueOption -import com.github.damontecres.wholphin.data.filter.FilterVideoType -import com.github.damontecres.wholphin.data.filter.GenreFilter import com.github.damontecres.wholphin.data.filter.ItemFilterBy -import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter -import com.github.damontecres.wholphin.data.filter.PlayedFilter -import com.github.damontecres.wholphin.data.filter.VideoTypeFilter -import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter @@ -88,6 +79,7 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler @@ -103,9 +95,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.genresApi -import org.jellyfin.sdk.api.client.extensions.localizationApi -import org.jellyfin.sdk.api.client.extensions.yearsApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType @@ -116,7 +105,6 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber -import java.util.TreeSet import java.util.UUID import kotlin.time.Duration @@ -391,79 +379,12 @@ class CollectionFolderViewModel } suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = - try { - when (filterOption) { - GenreFilter -> { - api.genresApi - .getGenres( - parentId = itemUuid, - userId = serverRepository.currentUser.value?.id, - ).content.items - .map { FilterValueOption(it.name ?: "", it.id) } - } - - FavoriteFilter, - PlayedFilter, - -> { - listOf( - FilterValueOption("True", null), - FilterValueOption("False", null), - ) - } - - OfficialRatingFilter -> { - api.localizationApi.getParentalRatings().content.map { - FilterValueOption(it.name ?: "", it.value) - } - } - - VideoTypeFilter -> { - FilterVideoType.entries.map { - FilterValueOption(it.readable, it) - } - } - - YearFilter -> { - api.yearsApi - .getYears( - parentId = itemUuid, - userId = serverRepository.currentUser.value?.id, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNull { - it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } - } - } - - DecadeFilter -> { - val items = TreeSet() - api.yearsApi - .getYears( - parentId = itemUuid, - userId = serverRepository.currentUser.value?.id, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNullTo(items) { - it.name - ?.toIntOrNull() - ?.div(10) - ?.times(10) - } - items.toList().sorted().map { FilterValueOption("$it's", it) } - } - - CommunityRatingFilter -> { - (1..10).map { - FilterValueOption("$it", it) - } - } - } - } catch (ex: Exception) { - Timber.e(ex, "Exception get filter value options for $filterOption") - listOf() - } + FilterUtils.getFilterOptionValues( + api, + serverRepository.currentUser.value?.id, + itemUuid, + filterOption, + ) suspend fun positionOfLetter(letter: Char): Int? = withContext(Dispatchers.IO) { 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 85ea35cb..d5897a6b 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 @@ -32,6 +32,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector @@ -183,7 +184,7 @@ fun DialogPopup( dismissOnClick: Boolean = true, waitToLoad: Boolean = true, properties: DialogProperties = DialogProperties(), - elevation: Dp = 3.dp, + elevation: Dp = 8.dp, ) { var waiting by remember { mutableStateOf(waitToLoad) } if (showDialog) { @@ -238,7 +239,7 @@ fun DialogPopupContent( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, dismissOnClick: Boolean = true, - elevation: Dp = 3.dp, + elevation: Dp = 8.dp, ) { val elevatedContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) @@ -246,6 +247,7 @@ fun DialogPopupContent( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier + .shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp)) .graphicsLayer { this.clip = true this.shape = RoundedCornerShape(28.0.dp) @@ -296,7 +298,7 @@ fun DialogPopup( onDismissRequest: () -> Unit, dismissOnClick: Boolean = true, properties: DialogProperties = DialogProperties(), - elevation: Dp = 3.dp, + elevation: Dp = 8.dp, ) = DialogPopup( showDialog = true, waitToLoad = params.fromLongClick, @@ -345,6 +347,7 @@ fun ScrollableDialog( .width(width) .heightIn(max = maxHeight) .focusable() + .shadow(elevation = 8.dp, shape = RoundedCornerShape(8.dp)) .background( MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), shape = RoundedCornerShape(8.dp), @@ -373,7 +376,7 @@ fun ScrollableDialog( fun BasicDialog( onDismissRequest: () -> Unit, properties: DialogProperties = DialogProperties(), - elevation: Dp = 3.dp, + elevation: Dp = 8.dp, content: @Composable () -> Unit, ) { Dialog( @@ -383,6 +386,7 @@ fun BasicDialog( Box( modifier = Modifier + .shadow(elevation = elevation, shape = RoundedCornerShape(8.dp)) .background( MaterialTheme.colorScheme.surfaceColorAtElevation(elevation), shape = RoundedCornerShape(8.dp), @@ -403,7 +407,7 @@ fun ConfirmDialog( onCancel: () -> Unit, onConfirm: () -> Unit, properties: DialogProperties = DialogProperties(), - elevation: Dp = 3.dp, + elevation: Dp = 8.dp, ) = BasicDialog( onDismissRequest = onCancel, properties = properties, 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 5bf996a3..7e552525 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 @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -22,7 +23,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable 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.mutableIntStateOf @@ -49,7 +52,14 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.DefaultPlaylistItemsOptions +import com.github.damontecres.wholphin.data.filter.FilterValueOption +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.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.DefaultItemFields @@ -61,26 +71,39 @@ 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 +import com.github.damontecres.wholphin.ui.components.FilterByButton import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.SortByButton +import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber import java.util.UUID import javax.inject.Inject import kotlin.time.Duration @@ -92,9 +115,22 @@ class PlaylistViewModel api: ApiClient, val navigationManager: NavigationManager, private val backdropService: BackdropService, + private val serverRepository: ServerRepository, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, ) : ItemViewModel(api) { val loading = MutableLiveData(LoadingState.Pending) val items = MutableLiveData>(listOf()) + val filterAndSort = + MutableStateFlow( + FilterAndSort( + filter = GetItemsFilter(), + sortAndDirection = + SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ), + ), + ) fun init(playlistId: UUID) { loading.value = LoadingState.Loading @@ -103,19 +139,93 @@ class PlaylistViewModel LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"), ) { val playlist = fetchItem(playlistId) - val request = - GetPlaylistItemsRequest( - playlistId = playlist.id, - fields = DefaultItemFields, + val libraryDisplayInfo = + serverRepository.currentUser.value?.let { user -> + libraryDisplayInfoDao.getItem(user, itemId) + } + val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() + val sortAndDirection = + libraryDisplayInfo?.sortAndDirection ?: SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, ) - val pager = ApiRequestPager(api, request, GetPlaylistItemsRequestHandler, viewModelScope).init() - withContext(Dispatchers.Main) { - items.value = pager - loading.value = LoadingState.Success + loadItems(filter, sortAndDirection) + } + } + + fun loadItems( + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + ) { + viewModelScope.launchIO { + backdropService.clearBackdrop() + loading.setValueOnMain(LoadingState.Loading) + this@PlaylistViewModel.filterAndSort.update { + FilterAndSort(filter, sortAndDirection) + } + + serverRepository.currentUser.value?.let { user -> + val playlistId = item.value!!.id + viewModelScope.launchIO { + val libraryDisplayInfo = + libraryDisplayInfoDao.getItem(user, itemId)?.copy( + filter = filter, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + ) + ?: LibraryDisplayInfo( + userId = user.rowId, + itemId = itemId, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + filter = filter, + viewOptions = null, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } + + val request = + filter.applyTo( + GetItemsRequest( + parentId = playlistId, + userId = user.id, + fields = DefaultItemFields, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), + ) + try { + val pager = + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + ).init() + + withContext(Dispatchers.Main) { + items.value = pager + loading.value = LoadingState.Success + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching playlist %s", itemId) + withContext(Dispatchers.Main) { + items.value = listOf() + loading.value = LoadingState.Error(ex) + } + } } } } + suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = + FilterUtils.getFilterOptionValues( + api, + serverRepository.currentUser.value?.id, + itemUuid, + filterOption, + ) + fun updateBackdrop(item: BaseItem) { viewModelScope.launchIO { backdropService.submit(item) @@ -123,6 +233,12 @@ class PlaylistViewModel } } +@Immutable +data class FilterAndSort( + val filter: GetItemsFilter, + val sortAndDirection: SortAndDirection, +) + @Composable fun PlaylistDetails( destination: Destination.MediaItem, @@ -136,78 +252,75 @@ fun PlaylistDetails( val loading by viewModel.loading.observeAsState(LoadingState.Pending) val playlist by viewModel.item.observeAsState(null) val items by viewModel.items.observeAsState(listOf()) + val filterAndSort by viewModel.filterAndSort.collectAsState() var longClickDialog by remember { mutableStateOf(null) } - when (val st = loading) { - is LoadingState.Error -> { - ErrorMessage(st, modifier) - } + val goToString = stringResource(R.string.go_to) + val playFromHereString = stringResource(R.string.play_from_here) - LoadingState.Pending, LoadingState.Loading -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - playlist?.let { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - PlaylistDetailsContent( - playlist = it, - items = items, - focusRequester = focusRequester, - onChangeBackdrop = viewModel::updateBackdrop, - onClickIndex = { index, _ -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it.id, - startIndex = index, - shuffle = false, - ), - ) - }, - onClickPlay = { shuffle -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it.id, - startIndex = 0, - shuffle = shuffle, - ), - ) - }, - onLongClickIndex = { index, item -> - longClickDialog = - DialogParams( - fromLongClick = true, - title = item.name ?: "", - items = - listOf( - DialogItem( - context.getString(R.string.go_to), - Icons.Default.ArrowForward, - ) { - viewModel.navigationManager.navigateTo(item.destination()) - }, - DialogItem( - context.getString(R.string.play_from_here), - Icons.Default.PlayArrow, - ) { - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it.id, - startIndex = index, - shuffle = false, - ), - ) - }, + PlaylistDetailsContent( + loadingState = loading, + playlist = playlist, + items = items, + onChangeBackdrop = viewModel::updateBackdrop, + onClickIndex = { index, _ -> + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = index, + shuffle = false, + filter = filterAndSort.filter, + sortAndDirection = filterAndSort.sortAndDirection, + ), + ) + }, + onClickPlay = { shuffle -> + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = 0, + shuffle = shuffle, + filter = filterAndSort.filter, + sortAndDirection = filterAndSort.sortAndDirection, + ), + ) + }, + onLongClickIndex = { index, item -> + longClickDialog = + DialogParams( + fromLongClick = true, + title = item.name ?: "", + items = + listOf( + DialogItem( + goToString, + Icons.Default.ArrowForward, + ) { + viewModel.navigationManager.navigateTo(item.destination()) + }, + DialogItem( + playFromHereString, + Icons.Default.PlayArrow, + ) { + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = index, + shuffle = false, + filter = filterAndSort.filter, + sortAndDirection = filterAndSort.sortAndDirection, ), - ) - }, - modifier = modifier, + ) + }, + ), ) - } - } - } + }, + filterAndSort = filterAndSort, + onFilterAndSortChange = viewModel::loadItems, + getPossibleFilterValues = viewModel::getFilterOptionValues, + modifier = modifier, + ) longClickDialog?.let { params -> DialogPopup( params = params, @@ -218,14 +331,17 @@ fun PlaylistDetails( @Composable fun PlaylistDetailsContent( - playlist: BaseItem, + playlist: BaseItem?, items: List, onClickIndex: (Int, BaseItem) -> Unit, onLongClickIndex: (Int, BaseItem) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit, onChangeBackdrop: (BaseItem) -> Unit, + filterAndSort: FilterAndSort, + onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, + loadingState: LoadingState, modifier: Modifier = Modifier, - focusRequester: FocusRequester = remember { FocusRequester() }, ) { var savedIndex by rememberSaveable { mutableIntStateOf(0) } var focusedIndex by remember { mutableIntStateOf(savedIndex) } @@ -234,6 +350,12 @@ fun PlaylistDetailsContent( LaunchedEffect(focusedItem) { focusedItem?.let(onChangeBackdrop) } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(loadingState) { + if (loadingState is LoadingState.Success || loadingState is LoadingState.Error) { + focusRequester.tryRequestFocus() + } + } val playButtonFocusRequester = remember { FocusRequester() } @@ -248,83 +370,123 @@ fun PlaylistDetailsContent( .fillMaxSize(), ) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(32.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 8.dp) .fillMaxWidth(), ) { PlaylistDetailsHeader( focusedItem = focusedItem, onClickPlay = onClickPlay, playButtonFocusRequester = playButtonFocusRequester, + focusRequester = if (items.isEmpty()) focusRequester else remember { FocusRequester() }, + filterAndSort = filterAndSort, + onFilterAndSortChange = onFilterAndSortChange, + getPossibleFilterValues = getPossibleFilterValues, modifier = Modifier - .padding(start = 16.dp) + .padding(start = 16.dp, top = 80.dp) .fillMaxWidth(.25f), ) - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Text( - text = playlist.name ?: stringResource(R.string.playlist), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displayMedium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - LazyColumn( - contentPadding = PaddingValues(8.dp), - modifier = - Modifier - .padding(bottom = 32.dp) - .fillMaxHeight() -// .fillMaxWidth(.8f) - .weight(1f) - .background( - MaterialTheme.colorScheme - .surfaceColorAtElevation(1.dp) - .copy(alpha = .75f), - shape = RoundedCornerShape(16.dp), - ).focusRequester(focusRequester) - .focusGroup() - .focusRestorer(focus), - ) { - itemsIndexed(items) { index, item -> - PlaylistItem( - item = item, - index = index, - onClick = { - savedIndex = index - item?.let { - onClickIndex.invoke(index, item) - } - }, - onLongClick = { - savedIndex = index - item?.let { - onLongClickIndex.invoke(index, item) - } - }, - modifier = - Modifier - .height(80.dp) - .ifElse( - index == savedIndex, - Modifier.focusRequester(focus), - ).onFocusChanged { - if (it.isFocused) { - focusedIndex = index - } - }.focusProperties { - left = playButtonFocusRequester - previous = playButtonFocusRequester - }, + when (loadingState) { + is LoadingState.Error -> { + ErrorMessage(loadingState, modifier) + } + + LoadingState.Pending, LoadingState.Loading -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = playlist?.name ?: stringResource(R.string.playlist), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displayMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) + if (items.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .padding(bottom = 32.dp) + .fillMaxHeight() +// .fillMaxWidth(.8f) + .weight(1f) + .background( + MaterialTheme.colorScheme + .surfaceColorAtElevation(1.dp) + .copy(alpha = .75f), + shape = RoundedCornerShape(16.dp), + ).focusProperties { + onExit = { + playButtonFocusRequester.tryRequestFocus() + } + }.focusRequester(focusRequester) + .focusGroup() + .focusRestorer(focus), + ) { + itemsIndexed(items) { index, item -> + PlaylistItem( + item = item, + index = index, + onClick = { + savedIndex = index + item?.let { + onClickIndex.invoke(index, item) + } + }, + onLongClick = { + savedIndex = index + item?.let { + onLongClickIndex.invoke(index, item) + } + }, + modifier = + Modifier + .height(80.dp) + .ifElse( + index == savedIndex, + Modifier.focusRequester(focus), + ).onFocusChanged { + if (it.isFocused) { + focusedIndex = index + } + }.focusProperties { + left = playButtonFocusRequester + previous = playButtonFocusRequester + }, + ) + } + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = + Modifier + .focusProperties { + onExit = { + playButtonFocusRequester.tryRequestFocus() + } + }.focusRequester(focusRequester) + .focusable(), + ) + } + } } } } @@ -338,6 +500,10 @@ fun PlaylistDetailsHeader( focusedItem: BaseItem?, onClickPlay: (shuffle: Boolean) -> Unit, playButtonFocusRequester: FocusRequester, + focusRequester: FocusRequester, + filterAndSort: FilterAndSort, + onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, modifier: Modifier = Modifier, ) { Column( @@ -346,13 +512,14 @@ fun PlaylistDetailsHeader( ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.focusRequester(playButtonFocusRequester), + modifier = Modifier, ) { ExpandablePlayButton( title = R.string.play, resume = Duration.ZERO, icon = Icons.Default.PlayArrow, onClick = { onClickPlay.invoke(false) }, + modifier = Modifier.focusRequester(playButtonFocusRequester), ) ExpandableFaButton( title = R.string.shuffle, @@ -360,16 +527,45 @@ fun PlaylistDetailsHeader( onClick = { onClickPlay.invoke(true) }, ) } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier, + ) { + FilterByButton( + filterOptions = DefaultPlaylistItemsOptions, + current = filterAndSort.filter, + onFilterChange = { + onFilterAndSortChange.invoke( + it, + filterAndSort.sortAndDirection, + ) + }, + getPossibleValues = getPossibleFilterValues, + modifier = Modifier.focusRequester(focusRequester), + ) + SortByButton( + sortOptions = BoxSetSortOptions, + current = filterAndSort.sortAndDirection, + onSortChange = { onFilterAndSortChange.invoke(filterAndSort.filter, it) }, + ) + } Text( text = focusedItem?.title ?: "", color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineLarge, + style = MaterialTheme.typography.headlineSmall, ) Text( text = focusedItem?.subtitle ?: "", color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleMedium, ) + if (focusedItem?.type == BaseItemKind.EPISODE && focusedItem.data.premiereDate != null) { + Text( + text = formatDateTime(focusedItem.data.premiereDate!!), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall, + ) + } OverviewText( overview = focusedItem?.data?.overview ?: "", maxLines = 10, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index 9ddf4bc3..3c22e8a9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -90,7 +90,7 @@ fun DiscoverMovieDetails( val people by viewModel.people.observeAsState(listOf()) val trailers by viewModel.trailers.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) - val recommended by viewModel.similar.observeAsState(listOf()) + val recommended by viewModel.recommended.observeAsState(listOf()) val loading by viewModel.loading.observeAsState(LoadingState.Loading) val userConfig by viewModel.userConfig.collectAsState(null) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) @@ -365,7 +365,7 @@ fun DiscoverMovieDetailsContent( item { ItemRow( title = stringResource(R.string.recommended), - items = similar, + items = recommended, onClickItem = { index, item -> position = RECOMMENDED_ROW onClickItem.invoke(index, item) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt index 4da6f938..79ccb8dd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.roundMinutes @@ -103,7 +104,8 @@ fun DiscoverMovieDetailsHeader( GenreText(it, Modifier.padding(bottom = padding)) } - movie.tagline?.let { tagline -> + val tagline = remember { movie.tagline?.takeIf { it.isNotNullOrBlank() } } + tagline?.let { Text( text = tagline, style = MaterialTheme.typography.bodyLarge, @@ -134,8 +136,9 @@ fun DiscoverMovieDetailsHeader( remember(movie.credits?.crew) { movie.credits ?.crew - ?.filter { it.job == "Directing" } + ?.filter { it.job == "Director" && it.name.isNotNullOrBlank() } ?.joinToString(", ") { it.name!! } + ?.takeIf { it.isNotNullOrBlank() } } directorName diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt index 0883023d..cc80d404 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import timber.log.Timber @HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class) class DiscoverMovieViewModel @@ -67,8 +68,8 @@ class DiscoverMovieViewModel val trailers = MutableLiveData>(listOf()) val people = MutableLiveData>(listOf()) - val similar = MutableLiveData>(listOf()) - val recommended = MutableLiveData>(listOf()) + val similar = MutableLiveData>() + val recommended = MutableLiveData>() val canCancelRequest = MutableStateFlow(false) val userConfig = seerrServerRepository.current.map { it?.config } @@ -99,6 +100,7 @@ class DiscoverMovieViewModel "Error fetching movie", ), ) { + Timber.v("Init for movie %s", item.id) val movie = fetchAndSetItem().await() val discoveredItem = DiscoverItem(movie) backdropService.submit(discoveredItem) @@ -116,7 +118,7 @@ class DiscoverMovieViewModel viewModelScope.launchIO { val result = seerrService.api.moviesApi - .movieMovieIdSimilarGet(movieId = item.id, page = 2) + .movieMovieIdSimilarGet(movieId = item.id, page = 1) .results ?.map(::DiscoverItem) .orEmpty() @@ -125,11 +127,11 @@ class DiscoverMovieViewModel viewModelScope.launchIO { val result = seerrService.api.moviesApi - .movieMovieIdRecommendationsGet(movieId = item.id, page = 2) + .movieMovieIdRecommendationsGet(movieId = item.id, page = 1) .results ?.map(::DiscoverItem) .orEmpty() - similar.setValueOnMain(result) + recommended.setValueOnMain(result) } } val people = @@ -147,7 +149,7 @@ class DiscoverMovieViewModel ?.filter { it.type == RelatedVideo.Type.TRAILER } ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } ?.map { - RemoteTrailer(it.name!!, it.url!!, null) + RemoteTrailer(it.name!!, it.url!!, it.site) }.orEmpty() this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 2cf93e8b..dcaed1c5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -89,6 +89,7 @@ fun DiscoverSeriesDetails( val item by viewModel.tvSeries.observeAsState() val seasons by viewModel.seasons.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf()) + val trailers by viewModel.trailers.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) val recommended by viewModel.recommended.observeAsState(listOf()) val userConfig by viewModel.userConfig.collectAsState(null) @@ -155,7 +156,7 @@ fun DiscoverSeriesDetails( trailerOnClick = { TrailerService.onClick(context, it, viewModel::navigateTo) }, - trailers = listOf(), + trailers = trailers, requestOnClick = { item.id?.let { id -> if (request4kEnabled) { @@ -255,7 +256,7 @@ fun DiscoverSeriesDetailsContent( val bringIntoViewRequester = remember { BringIntoViewRequester() } var position by rememberInt() - val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } } val playFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequesters.getOrNull(position)?.tryRequestFocus() @@ -398,7 +399,7 @@ fun DiscoverSeriesDetailsContent( item { ItemRow( title = stringResource(R.string.recommended), - items = similar, + items = recommended, onClickItem = { index, item -> position = RECOMMENDED_ROW onClickItem.invoke(index, item) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt index 4803aee4..f7b11137 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -4,17 +4,20 @@ import android.content.Context import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest import com.github.damontecres.wholphin.api.seerr.model.Season import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain @@ -36,6 +39,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import timber.log.Timber @HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class) class DiscoverSeriesViewModel @@ -62,8 +66,8 @@ class DiscoverSeriesViewModel val seasons = MutableLiveData>(listOf()) val trailers = MutableLiveData>(listOf()) val people = MutableLiveData>(listOf()) - val similar = MutableLiveData>(listOf()) - val recommended = MutableLiveData>(listOf()) + val similar = MutableLiveData>() + val recommended = MutableLiveData>() val canCancelRequest = MutableStateFlow(false) val userConfig = seerrServerRepository.current.map { it?.config } @@ -94,6 +98,7 @@ class DiscoverSeriesViewModel "Error fetching movie", ), ) { + Timber.v("Init for tv %s", item.id) val tv = fetchAndSetItem().await() val discoveredItem = DiscoverItem(tv) backdropService.submit(discoveredItem) @@ -110,8 +115,8 @@ class DiscoverSeriesViewModel if (!similar.isInitialized) { viewModelScope.launchIO { val result = - seerrService.api.moviesApi - .movieMovieIdSimilarGet(movieId = item.id, page = 2) + seerrService.api.tvApi + .tvTvIdSimilarGet(tvId = item.id, page = 1) .results ?.map(::DiscoverItem) .orEmpty() @@ -119,12 +124,12 @@ class DiscoverSeriesViewModel } viewModelScope.launchIO { val result = - seerrService.api.moviesApi - .movieMovieIdRecommendationsGet(movieId = item.id, page = 2) + seerrService.api.tvApi + .tvTvIdRecommendationsGet(tvId = item.id, page = 1) .results ?.map(::DiscoverItem) .orEmpty() - similar.setValueOnMain(result) + recommended.setValueOnMain(result) } } val people = @@ -137,6 +142,15 @@ class DiscoverSeriesViewModel ?.map(::DiscoverItem) .orEmpty() this@DiscoverSeriesViewModel.people.setValueOnMain(people) + + val trailers = + tv.relatedVideos + ?.filter { it.type == RelatedVideo.Type.TRAILER } + ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } + ?.map { + RemoteTrailer(it.name!!, it.url!!, it.site) + }.orEmpty() + this@DiscoverSeriesViewModel.trailers.setValueOnMain(trailers) } fun navigateTo(destination: Destination) { 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 dc5aaa95..b19e7900 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 @@ -55,7 +55,6 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.SeriesName import com.github.damontecres.wholphin.ui.components.TabRow -import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp @@ -215,9 +214,6 @@ fun SeriesOverviewContent( if (interactionSource.collectIsFocusedAsState().value) { onFocusEpisode.invoke(episodeIndex) } - val cornerText = - episode?.data?.indexNumber?.let { "E$it" } - ?: episode?.data?.premiereDate?.let(::formatDateTime) BannerCard( name = episode?.name, item = episode, @@ -226,7 +222,7 @@ fun SeriesOverviewContent( ?.aspectRatio ?.coerceAtLeast(AspectRatios.FOUR_THREE) ?: (AspectRatios.WIDE), - cornerText = cornerText, + cornerText = episode?.ui?.episodeCornerText, played = episode?.data?.userData?.played ?: false, playPercent = episode?.data?.userData?.playedPercentage 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 4e5f33c7..8ae7a579 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 @@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import com.google.common.cache.CacheBuilder import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -57,6 +58,7 @@ import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy @@ -121,6 +123,7 @@ class SeriesViewModel ) + Dispatchers.IO, ) { Timber.v("Start") + addCloseable { themeSongPlayer.stop() } val item = fetchItem(seriesId) backdropService.submit(item) @@ -292,14 +295,16 @@ class SeriesViewModel listOf( ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCE_COUNT, - ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.CUSTOM_RATING, - ItemFields.TRICKPLAY, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.PEOPLE, ), ) + Timber.v( + "loadEpisodesInternal: episodeId=%s, episodeNumber=%s", + episodeId, + episodeNumber, + ) val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) pager.init(episodeNumber ?: 0) val initialIndex = @@ -503,19 +508,34 @@ class SeriesViewModel } private var peopleInEpisodeJob: Job? = null + private val peopleInEpisodeCache = + CacheBuilder + .newBuilder() + .maximumSize(25) + .build>() suspend fun lookupPeopleInEpisode(item: BaseItem) { peopleInEpisodeJob?.cancel() if (peopleInEpisode.value?.itemId != item.id) { peopleInEpisode.setValueOnMain(PeopleInItem()) + val result = + peopleInEpisodeCache + .get(item.id) { + viewModelScope.async(Dispatchers.IO) { + val list = + api.userLibraryApi + .getItem(item.id) + .content.people + ?.map { Person.fromDto(it, api) } + .orEmpty() + + PeopleInItem(item.id, list) + } + } peopleInEpisodeJob = viewModelScope.launch(ExceptionHandler()) { delay(250) - val people = - item.data.people - ?.letNotEmpty { it.map { Person.fromDto(it, api) } } - .orEmpty() - peopleInEpisode.setValueOnMain(PeopleInItem(item.id, people)) + peopleInEpisode.setValueOnMain(result.await()) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt index 4d3bdc42..53b04161 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt @@ -18,18 +18,17 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import com.github.damontecres.wholphin.ui.tryRequestFocus @Composable fun DiscoverPage( @@ -46,21 +45,16 @@ fun DiscoverPage( stringResource(R.string.request), ) var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } val tabFocusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } - val firstTabFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - LaunchedEffect(selectedTabIndex) { logTab("discover", selectedTabIndex) preferencesViewModel.saveRememberedTab(preferences, NavDrawerItem.Discover.id, selectedTabIndex) - preferencesViewModel.backdropService.clearBackdrop() } + OneTimeLaunchedEffect { preferencesViewModel.backdropService.clearBackdrop() } var showHeader by rememberSaveable { mutableStateOf(true) } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus("page") } Column( modifier = modifier, ) { @@ -73,8 +67,7 @@ fun DiscoverPage( selectedTabIndex = selectedTabIndex, modifier = Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) - .focusRequester(firstTabFocusRequester), + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp), tabs = tabs, onClick = { selectedTabIndex = it }, focusRequesters = tabFocusRequesters, @@ -87,8 +80,7 @@ fun DiscoverPage( preferences = preferences, modifier = Modifier - .fillMaxSize() - .focusRequester(focusRequester), + .fillMaxSize(), ) } @@ -98,8 +90,7 @@ fun DiscoverPage( focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), modifier = Modifier - .fillMaxSize() - .focusRequester(focusRequester), + .fillMaxSize(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index fc58bf2d..691b00cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -197,7 +197,7 @@ fun SeerrDiscoverPage( listOf(state.trending, state.movies, state.tv, state.upcomingMovies, state.upcomingTv) val ratingMap by viewModel.rating.collectAsState() - val focusRequesters = remember(2) { List(rows.size) { FocusRequester() } } + val focusRequesters = remember(rows) { List(rows.size) { FocusRequester() } } var position by rememberPosition(0, -1) val focusedItem = remember(position) { @@ -212,6 +212,8 @@ fun SeerrDiscoverPage( LaunchedEffect(state.trending) { if (!firstFocused && state.trending.items is DataLoadingState.Success<*>) { firstFocused = focusRequesters.getOrNull(0)?.tryRequestFocus("discover") == true + } else if (firstFocused) { + focusRequesters.getOrNull(position.row)?.tryRequestFocus() } } 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 6746950a..3a838815 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 @@ -40,6 +40,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -112,6 +113,9 @@ class SeerrRequestsViewModel state.update { it.copy(requests = DataLoadingState.Success(results)) } } + }.catch { ex -> + Timber.e(ex, "Error fetching requests") + state.update { it.copy(requests = DataLoadingState.Error(ex)) } }.launchIn(viewModelScope) } 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 d71c0c1f..4c58b00c 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 @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.main -import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable @@ -25,7 +24,6 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -49,7 +47,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress @@ -65,6 +62,7 @@ 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 import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp @@ -88,28 +86,15 @@ fun HomePage( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - var firstLoad by rememberSaveable { mutableStateOf(true) } LaunchedEffect(Unit) { - viewModel.init(preferences).join() - firstLoad = false + viewModel.init() } val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading) val watchingRows by viewModel.watchingRows.observeAsState(listOf()) val latestRows by viewModel.latestRows.observeAsState(listOf()) - LaunchedEffect(loading) { - val state = loading - if (!firstLoad && state is LoadingState.Error) { - // After the first load, refreshes occur in the background and an ErrorMessage won't show - // So send a Toast on errors instead - Toast - .makeText( - context, - "Home refresh error: ${state.localizedMessage}", - Toast.LENGTH_LONG, - ).show() - } - } + + val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows } when (val state = loading) { is LoadingState.Error -> { @@ -127,7 +112,7 @@ fun HomePage( var showPlaylistDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) HomePageContent( - watchingRows + latestRows, + homeRows = homeRows, onClickItem = { position, item -> viewModel.navigationManager.navigateTo(item.destination()) }, @@ -219,15 +204,15 @@ fun HomePageContent( val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } } var firstFocused by remember { mutableStateOf(false) } LaunchedEffect(homeRows) { - if (!firstFocused) { + if (!firstFocused && homeRows.isNotEmpty()) { if (position.row >= 0) { - rowFocusRequesters[position.row].tryRequestFocus() + val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex) + rowFocusRequesters.getOrNull(index)?.tryRequestFocus() firstFocused = true } else { // Waiting for the first home row to load, then focus on it homeRows - .indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } - .takeIf { it >= 0 } + .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } ?.let { rowFocusRequesters[it].tryRequestFocus() firstFocused = true @@ -238,7 +223,9 @@ fun HomePageContent( } } LaunchedEffect(position) { - listState.animateScrollToItem(position.row) + if (position.row >= 0) { + listState.animateScrollToItem(position.row) + } } LaunchedEffect(onUpdateBackdrop, focusedItem) { focusedItem?.let { onUpdateBackdrop.invoke(it) } @@ -337,21 +324,11 @@ fun HomePageContent( .focusRequester(rowFocusRequesters[rowIndex]) .animateItem(), cardContent = { index, item, cardModifier, onClick, onLongClick -> - val cornerText = - remember(item) { - item?.data?.indexNumber?.let { "E$it" } - ?: item - ?.data - ?.userData - ?.unplayedItemCount - ?.takeIf { it > 0 } - ?.let { abbreviateNumber(it) } - } BannerCard( name = item?.data?.seriesName ?: item?.name, item = item, aspectRatio = AspectRatios.TALL, - cornerText = cornerText, + cornerText = item?.ui?.episdodeUnplayedCornerText, played = item?.data?.userData?.played ?: false, favorite = item?.favorite ?: false, playPercent = @@ -424,19 +401,17 @@ fun HomePageHeader( item: BaseItem?, modifier: Modifier = Modifier, ) { - item?.let { - val isEpisode = item.type == BaseItemKind.EPISODE - val dto = item.data - HomePageHeader( - title = item.title, - subtitle = if (isEpisode) dto.name else null, - overview = dto.overview, - overviewTwoLines = isEpisode, - quickDetails = item.ui.quickDetails, - timeRemaining = item.timeRemainingOrRuntime, - modifier = modifier, - ) - } + val isEpisode = item?.type == BaseItemKind.EPISODE + val dto = item?.data + HomePageHeader( + title = item?.title, + subtitle = if (isEpisode) dto?.name else null, + overview = dto?.overview, + overviewTwoLines = isEpisode, + quickDetails = item?.ui?.quickDetails, + timeRemaining = item?.timeRemainingOrRuntime, + modifier = modifier, + ) } @Composable @@ -445,7 +420,7 @@ fun HomePageHeader( subtitle: String?, overview: String?, overviewTwoLines: Boolean, - quickDetails: AnnotatedString, + quickDetails: AnnotatedString?, timeRemaining: Duration?, modifier: Modifier = Modifier, ) { @@ -473,7 +448,7 @@ fun HomePageHeader( subtitle?.let { EpisodeName(it) } - QuickDetails(quickDetails, timeRemaining) + QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining) val overviewModifier = Modifier .padding(0.dp) 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 e2704ad8..1911e34d 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 @@ -14,9 +14,11 @@ import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingExceptionHandler @@ -24,7 +26,6 @@ import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -47,6 +48,7 @@ class HomeViewModel private val datePlayedService: DatePlayedService, private val latestNextUpService: LatestNextUpService, private val backdropService: BackdropService, + private val userPreferencesService: UserPreferencesService, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) val refreshState = MutableLiveData(LoadingState.Pending) @@ -57,18 +59,11 @@ class HomeViewModel init { datePlayedService.invalidateAll() + init() } - fun init(preferences: UserPreferences): Job { - val reload = loadingState.value != LoadingState.Success - if (reload) { - loadingState.value = LoadingState.Loading - } - refreshState.value = LoadingState.Loading - this.preferences = preferences - val prefs = preferences.appPreferences.homePagePreferences - val limit = prefs.maxItemsPerRow - return viewModelScope.launch( + fun init() { + viewModelScope.launch( Dispatchers.IO + LoadingExceptionHandler( loadingState, @@ -76,67 +71,83 @@ class HomeViewModel ), ) { Timber.d("init HomeViewModel") + val reload = loadingState.value != LoadingState.Success + if (reload) { + loadingState.setValueOnMain(LoadingState.Loading) + } + refreshState.setValueOnMain(LoadingState.Loading) + this@HomeViewModel.preferences = userPreferencesService.getCurrent() + val prefs = preferences.appPreferences.homePagePreferences + val limit = prefs.maxItemsPerRow if (reload) { backdropService.clearBackdrop() } - - serverRepository.currentUserDto.value?.let { userDto -> - val includedIds = - navDrawerItemRepository - .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) - .filter { it is ServerNavDrawerItem } - .map { (it as ServerNavDrawerItem).itemId } - val resume = latestNextUpService.getResume(userDto.id, limit, true) - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - ) - val watching = - buildList { - if (prefs.combineContinueNext) { - val items = latestNextUpService.buildCombined(resume, nextUp) - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.continue_watching), - items = items, - ), - ) - } else { - if (resume.isNotEmpty()) { + try { + serverRepository.currentUserDto.value?.let { userDto -> + val includedIds = + navDrawerItemRepository + .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) + .filter { it is ServerNavDrawerItem } + .map { (it as ServerNavDrawerItem).itemId } + val resume = latestNextUpService.getResume(userDto.id, limit, true) + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + ) + val watching = + buildList { + if (prefs.combineContinueNext) { + val items = latestNextUpService.buildCombined(resume, nextUp) add( HomeRowLoadingState.Success( title = context.getString(R.string.continue_watching), - items = resume, - ), - ) - } - if (nextUp.isNotEmpty()) { - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.next_up), - items = nextUp, + items = items, ), ) + } else { + if (resume.isNotEmpty()) { + add( + HomeRowLoadingState.Success( + title = context.getString(R.string.continue_watching), + items = resume, + ), + ) + } + if (nextUp.isNotEmpty()) { + add( + HomeRowLoadingState.Success( + title = context.getString(R.string.next_up), + items = nextUp, + ), + ) + } } } - } - val latest = latestNextUpService.getLatest(userDto, limit, includedIds) - val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } + val latest = latestNextUpService.getLatest(userDto, limit, includedIds) + val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } - withContext(Dispatchers.Main) { - this@HomeViewModel.watchingRows.value = watching - if (reload) { - this@HomeViewModel.latestRows.value = pendingLatest + withContext(Dispatchers.Main) { + this@HomeViewModel.watchingRows.value = watching + if (reload) { + this@HomeViewModel.latestRows.value = pendingLatest + } + loadingState.value = LoadingState.Success } - loadingState.value = LoadingState.Success + refreshState.setValueOnMain(LoadingState.Success) + val loadedLatest = latestNextUpService.loadLatest(latest) + this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) + } + } catch (ex: Exception) { + Timber.e(ex) + if (!reload) { + loadingState.setValueOnMain(LoadingState.Error(ex)) + } else { + showToast(context, "Error refreshing home: ${ex.localizedMessage}") } - refreshState.setValueOnMain(LoadingState.Success) - val loadedLatest = latestNextUpService.loadLatest(latest) - this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) } } } @@ -147,7 +158,7 @@ class HomeViewModel ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) withContext(Dispatchers.Main) { - init(preferences) + init() } } @@ -157,7 +168,7 @@ class HomeViewModel ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) withContext(Dispatchers.Main) { - init(preferences) + init() } } 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 43b8841d..70ef71d2 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 @@ -279,6 +279,7 @@ fun DestinationContent( } SeerrItemType.PERSON -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverPersonPage( person = destination.item, modifier = modifier, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 10c48451..f38d50bd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.nav import android.content.Context import androidx.activity.compose.BackHandler import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -14,12 +13,10 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.KeyboardArrowLeft @@ -27,13 +24,10 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf 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 import androidx.compose.ui.draw.drawBehind @@ -87,6 +81,7 @@ import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.setup.UserIconCardImage import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.toServerString @@ -94,11 +89,12 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.CollectionType import timber.log.Timber import java.util.UUID @@ -108,15 +104,16 @@ import javax.inject.Inject class NavDrawerViewModel @Inject constructor( + private val api: ApiClient, private val navDrawerItemRepository: NavDrawerItemRepository, val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, private val seerrServerRepository: SeerrServerRepository, ) : ViewModel() { - // private var all: List? = null val moreLibraries = MutableLiveData>(null) val libraries = MutableLiveData>(listOf()) + val selectedIndex = MutableLiveData(-1) val showMore = MutableLiveData(false) @@ -130,7 +127,6 @@ class NavDrawerViewModel fun init() { viewModelScope.launchIO { val all = navDrawerItemRepository.getNavDrawerItems() -// this@NavDrawerViewModel.all = all val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) val moreLibraries = all.toMutableList().apply { removeAll(libraries) } @@ -184,6 +180,37 @@ class NavDrawerViewModel } } + fun onClickDrawerItem( + index: Int, + item: NavDrawerItem, + ) { + if (item !is NavDrawerItem.More) setShowMore(false) + when (item) { + NavDrawerItem.Favorites -> { + setIndex(index) + navigationManager.navigateToFromDrawer( + Destination.Favorites, + ) + } + + NavDrawerItem.More -> { + setShowMore(!showMore.value!!) + } + + NavDrawerItem.Discover -> { + setIndex(index) + navigationManager.navigateToFromDrawer( + Destination.Discover, + ) + } + + is ServerNavDrawerItem -> { + setIndex(index) + navigationManager.navigateToFromDrawer(item.destination) + } + } + } + fun setIndex(index: Int) { selectedIndex.value = index } @@ -191,6 +218,8 @@ class NavDrawerViewModel fun setShowMore(value: Boolean) { showMore.value = value } + + fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id) } sealed interface NavDrawerItem { @@ -245,7 +274,7 @@ fun NavDrawer( viewModel: NavDrawerViewModel = hiltViewModel( LocalView.current.findViewTreeViewModelStoreOwner()!!, - key = "${server?.id}_${user?.id}", // Keyed to the server & user to ensure its reset when switching either + key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either ), ) { val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -266,76 +295,14 @@ fun NavDrawer( LaunchedEffect(Unit) { viewModel.init() } val showMore by viewModel.showMore.observeAsState(false) -// val libraries = if (showPinnedOnly) pinnedLibraries else allLibraries // A negative index is a built in page, >=0 is a library val selectedIndex by viewModel.selectedIndex.observeAsState(-1) - var focusedIndex by remember { mutableIntStateOf(Int.MIN_VALUE) } - val derivedFocusedIndex by remember { derivedStateOf { focusedIndex } } - - fun setShowMore(value: Boolean) { - viewModel.setShowMore(value) - } BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) { - setShowMore(false) + viewModel.setShowMore(false) } - val onClick = { index: Int, item: NavDrawerItem -> - when (item) { - NavDrawerItem.Favorites -> { - viewModel.setIndex(index) - viewModel.navigationManager.navigateToFromDrawer( - Destination.Favorites, - ) - } - - NavDrawerItem.More -> { - setShowMore(!showMore) - } - - NavDrawerItem.Discover -> { - viewModel.setIndex(index) - viewModel.navigationManager.navigateToFromDrawer( - Destination.Discover, - ) - } - - is ServerNavDrawerItem -> { - viewModel.setIndex(index) - viewModel.navigationManager.navigateToFromDrawer(item.destination) - } - } - } - // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 - if (false && preferences.appPreferences.interfacePreferences.navDrawerSwitchOnFocus) { - LaunchedEffect(derivedFocusedIndex) { - val index = derivedFocusedIndex - delay(600) - if (index != selectedIndex) { - if (index == -1) { - viewModel.setIndex(-1) - viewModel.navigationManager.goToHome() - } else if (index in libraries.indices) { - if (moreLibraries.isEmpty() || index != libraries.lastIndex) { - libraries.getOrNull(index)?.let { - onClick.invoke(index, it) - } - } - } else { - val newIndex = libraries.size - index + 1 - if (newIndex in moreLibraries.indices) { - moreLibraries.getOrNull(newIndex)?.let { - onClick.invoke(index, it) - } - } - } - } - } - } - - val closedDrawerWidth = 40.dp - val drawerWidth by animateDpAsState(if (drawerState.isOpen) 260.dp else closedDrawerWidth) - val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp) + val closedDrawerWidth = NavigationDrawerItemDefaults.CollapsedDrawerItemWidth val drawerBackground by animateColorAsState( if (drawerState.isOpen) { MaterialTheme.colorScheme.surface @@ -377,20 +344,17 @@ fun NavDrawer( modifier = Modifier .fillMaxHeight() - .width(drawerWidth) .drawBehind { drawRect(drawerBackground) }, ) { // Even though some must be clicked, focusing on it should clear other focused items val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE } - IconNavItem( - text = user?.name ?: "", - subtext = server?.name ?: server?.url, - icon = Icons.Default.AccountCircle, - selected = false, + val userImageUrl = remember(user) { viewModel.getUserImage(user) } + ProfileIcon( + user = user, + imageUrl = userImageUrl, + serverName = server.name ?: server.url, drawerOpen = drawerState.isOpen, interactionSource = interactionSource, onClick = { @@ -398,7 +362,7 @@ fun NavDrawer( SetupDestination.UserList(server), ) }, - modifier = Modifier.padding(start = drawerPadding), + modifier = Modifier, ) LazyColumn( state = listState, @@ -421,13 +385,10 @@ fun NavDrawer( scrollToSelected() } } - }.fillMaxHeight() - .padding(start = drawerPadding), + }.fillMaxHeight(), ) { item { val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - LaunchedEffect(focused) { if (focused) focusedIndex = -2 } IconNavItem( text = stringResource(R.string.search), icon = Icons.Default.Search, @@ -444,13 +405,11 @@ fun NavDrawer( .ifElse( selectedIndex == -2, Modifier.focusRequester(focusRequester), - ).animateItem(), + ), ) } item { val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - LaunchedEffect(focused) { if (focused) focusedIndex = -1 } IconNavItem( text = stringResource(R.string.home), icon = Icons.Default.Home, @@ -470,13 +429,11 @@ fun NavDrawer( .ifElse( selectedIndex == -1, Modifier.focusRequester(focusRequester), - ).animateItem(), + ), ) } itemsIndexed(libraries) { index, it -> val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - LaunchedEffect(focused) { if (focused) focusedIndex = index } NavItem( library = it, selected = selectedIndex == index, @@ -484,31 +441,26 @@ fun NavDrawer( drawerOpen = drawerState.isOpen, interactionSource = interactionSource, onClick = { - onClick.invoke(index, it) - if (it !is NavDrawerItem.More) setShowMore(false) + viewModel.onClickDrawerItem(index, it) }, modifier = Modifier .ifElse( selectedIndex == index, Modifier.focusRequester(focusRequester), - ).animateItem(), + ), ) } if (showMore) { itemsIndexed(moreLibraries) { index, it -> val adjustedIndex = (index + libraries.size + 1) val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - LaunchedEffect(focused) { - if (focused) focusedIndex = adjustedIndex - } NavItem( library = it, selected = selectedIndex == adjustedIndex, moreExpanded = showMore, drawerOpen = drawerState.isOpen, - onClick = { onClick.invoke(adjustedIndex, it) }, + onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) }, containerColor = if (drawerState.isOpen) { MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) @@ -521,14 +473,12 @@ fun NavDrawer( .ifElse( selectedIndex == adjustedIndex, Modifier.focusRequester(focusRequester), - ).animateItem(), + ), ) } } item { val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE } IconNavItem( text = stringResource(R.string.settings), icon = Icons.Default.Settings, @@ -542,7 +492,7 @@ fun NavDrawer( ), ) }, - modifier = Modifier.animateItem(), + modifier = Modifier, ) } } @@ -572,6 +522,45 @@ fun NavDrawer( } } +@Composable +fun NavigationDrawerScope.ProfileIcon( + user: JellyfinUser, + imageUrl: String?, + serverName: String, + drawerOpen: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + NavigationDrawerItem( + modifier = modifier, + selected = false, + onClick = onClick, + leadingContent = { + UserIconCardImage( + id = user.id, + name = user.name, + imageUrl = imageUrl, + alpha = if (drawerOpen) 1f else .5f, + modifier = Modifier.fillMaxSize(), + ) + }, + supportingContent = { + Text( + text = serverName, + maxLines = 1, + ) + }, + interactionSource = interactionSource, + ) { + Text( + modifier = Modifier, + text = user.name ?: user.id.toString(), + maxLines = 1, + ) + } +} + @Composable fun NavigationDrawerScope.IconNavItem( text: String, @@ -594,7 +583,7 @@ fun NavigationDrawerScope.IconNavItem( icon, contentDescription = null, tint = color, - modifier = Modifier.padding(0.dp), + modifier = Modifier, ) }, supportingContent = @@ -630,29 +619,31 @@ fun NavigationDrawerScope.NavItem( val context = LocalContext.current val useFont = library !is ServerNavDrawerItem || library.type != CollectionType.LIVETV val icon = - when (library) { - NavDrawerItem.Favorites -> { - R.string.fa_heart - } + remember(library) { + when (library) { + NavDrawerItem.Favorites -> { + R.string.fa_heart + } - NavDrawerItem.More -> { - R.string.fa_ellipsis - } + NavDrawerItem.More -> { + R.string.fa_ellipsis + } - NavDrawerItem.Discover -> { - R.string.fa_magnifying_glass_plus - } + NavDrawerItem.Discover -> { + R.string.fa_magnifying_glass_plus + } - is ServerNavDrawerItem -> { - when (library.type) { - CollectionType.MOVIES -> R.string.fa_film - CollectionType.TVSHOWS -> R.string.fa_tv - CollectionType.HOMEVIDEOS -> R.string.fa_video - CollectionType.LIVETV -> R.drawable.gf_dvr - CollectionType.MUSIC -> R.string.fa_music - CollectionType.BOXSETS -> R.string.fa_open_folder - CollectionType.PLAYLISTS -> R.string.fa_list_ul - else -> R.string.fa_film + is ServerNavDrawerItem -> { + when (library.type) { + CollectionType.MOVIES -> R.string.fa_film + CollectionType.TVSHOWS -> R.string.fa_tv + CollectionType.HOMEVIDEOS -> R.string.fa_video + CollectionType.LIVETV -> R.drawable.gf_dvr + CollectionType.MUSIC -> R.string.fa_music + CollectionType.BOXSETS -> R.string.fa_open_folder + CollectionType.PLAYLISTS -> R.string.fa_list_ul + else -> R.string.fa_film + } } } } @@ -687,14 +678,17 @@ fun NavigationDrawerScope.NavItem( } } }, - trailingContent = { + trailingContent = if (library is NavDrawerItem.More) { - Icon( - imageVector = if (moreExpanded) Icons.Default.ArrowDropDown else Icons.Default.KeyboardArrowLeft, - contentDescription = null, - ) - } - }, + { + Icon( + imageVector = if (moreExpanded) Icons.Default.ArrowDropDown else Icons.Default.KeyboardArrowLeft, + contentDescription = null, + ) + } + } else { + null + }, interactionSource = interactionSource, ) { Text( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt index 309f099b..2b7d61bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt @@ -4,12 +4,14 @@ import com.github.damontecres.wholphin.data.model.Chapter import org.jellyfin.sdk.model.api.TrickplayInfo data class CurrentMediaInfo( + val sourceId: String?, + val videoStream: SimpleVideoStream?, val audioStreams: List, val subtitleStreams: List, val chapters: List, val trickPlayInfo: TrickplayInfo?, ) { companion object { - val EMPTY = CurrentMediaInfo(listOf(), listOf(), listOf(), null) + val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null) } } 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 0c3c4f2b..8e8744fd 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 @@ -72,7 +72,6 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton @@ -107,16 +106,16 @@ fun PlaybackPage( preferences: UserPreferences, destination: Destination, modifier: Modifier = Modifier, - viewModel: PlaybackViewModel = hiltViewModel(), + viewModel: PlaybackViewModel = + hiltViewModel( + creationCallback = { it.create(destination) }, + ), ) { LifecycleStartEffect(destination) { onStopOrDispose { viewModel.release() } } - LaunchedEffect(destination) { - viewModel.init(destination, preferences) - } val loading by viewModel.loading.observeAsState(LoadingState.Loading) when (val st = loading) { @@ -131,497 +130,518 @@ fun PlaybackPage( } LoadingState.Success -> { - val prefs = preferences.appPreferences.playbackPreferences - val scope = rememberCoroutineScope() - val configuration = LocalConfiguration.current - val density = LocalDensity.current - - val player = viewModel.player - val mediaInfo by viewModel.currentMediaInfo.observeAsState() - val userDto by viewModel.currentUserDto.observeAsState() - - val currentPlayback by viewModel.currentPlayback.collectAsState() - val currentItemPlayback by viewModel.currentItemPlayback.observeAsState( - ItemPlayback( - userId = -1, - itemId = UUID.randomUUID(), - ), + val playerState by viewModel.currentPlayer.collectAsState() + PlaybackPageContent( + playerState = playerState!!, + preferences = preferences, + destination = destination, + viewModel = viewModel, + modifier = modifier, ) - val currentSegment by viewModel.currentSegment.observeAsState(null) - var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) } + } + } +} - val cues by viewModel.subtitleCues.observeAsState(listOf()) - var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } +@OptIn(UnstableApi::class) +@Composable +fun PlaybackPageContent( + playerState: PlayerState, + preferences: UserPreferences, + destination: Destination, + modifier: Modifier = Modifier, + viewModel: PlaybackViewModel, +) { + val player = playerState.player + val playerBackend = playerState.backend - val nextUp by viewModel.nextUp.observeAsState(null) - val playlist by viewModel.playlist.observeAsState(Playlist(listOf())) + val prefs = preferences.appPreferences.playbackPreferences + val scope = rememberCoroutineScope() + val configuration = LocalConfiguration.current + val density = LocalDensity.current + val mediaInfo by viewModel.currentMediaInfo.observeAsState() + val userDto by viewModel.currentUserDto.observeAsState() - val subtitleSearch by viewModel.subtitleSearch.observeAsState(null) - val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language) + val currentPlayback by viewModel.currentPlayback.collectAsState() + val currentItemPlayback by viewModel.currentItemPlayback.observeAsState( + ItemPlayback( + userId = -1, + itemId = UUID.randomUUID(), + ), + ) + val currentSegment by viewModel.currentSegment.observeAsState(null) + var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) } - var playbackDialog by remember { mutableStateOf(null) } - OneTimeLaunchedEffect { - if (prefs.playerBackend == PlayerBackend.MPV) { - scope.launch(Dispatchers.IO + ExceptionHandler()) { - preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( - configuration, - density, - ) - } - } - } - AmbientPlayerListener(player) - var contentScale by remember { - mutableStateOf( - if (prefs.playerBackend == PlayerBackend.MPV) { - ContentScale.FillBounds - } else { - prefs.globalContentScale.scale - }, - ) - } - var playbackSpeed by remember { mutableFloatStateOf(1.0f) } - LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } + val cues by viewModel.subtitleCues.observeAsState(listOf()) + var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } - val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO - LaunchedEffect(subtitleDelay) { - (player as? MpvPlayer)?.subtitleDelay = subtitleDelay - } + val nextUp by viewModel.nextUp.observeAsState(null) + val playlist by viewModel.playlist.observeAsState(Playlist(listOf())) - val presentationState = rememberPresentationState(player, false) - val scaledModifier = - Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) - val focusRequester = remember { FocusRequester() } - val playPauseState = rememberPlayPauseButtonState(player) - val seekBarState = rememberSeekBarState(player, scope) + val subtitleSearch by viewModel.subtitleSearch.observeAsState(null) + val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language) - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } - val controllerViewState = remember { viewModel.controllerViewState } - - var skipIndicatorDuration by remember { mutableLongStateOf(0L) } - LaunchedEffect(controllerViewState.controlsVisible) { - // If controller shows/hides, immediately cancel the skip indicator - skipIndicatorDuration = 0L - } - var skipPosition by remember { mutableLongStateOf(0L) } - val updateSkipIndicator = { delta: Long -> - if ((skipIndicatorDuration > 0 && delta < 0) || (skipIndicatorDuration < 0 && delta > 0)) { - skipIndicatorDuration = 0 - } - skipIndicatorDuration += delta - skipPosition = player.currentPosition - } - val keyHandler = - PlaybackKeyHandler( - player = player, - controlsEnabled = nextUp == null, - skipWithLeftRight = true, - seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, - seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, - controllerViewState = controllerViewState, - updateSkipIndicator = updateSkipIndicator, - skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, - onInteraction = viewModel::reportInteraction, - oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause, - onStop = { - player.stop() - viewModel.navigationManager.goBack() - }, - onPlaybackDialogTypeClick = { playbackDialog = it }, - ) - - val onPlaybackActionClick: (PlaybackAction) -> Unit = { - when (it) { - is PlaybackAction.PlaybackSpeed -> { - playbackSpeed = it.value - } - - is PlaybackAction.Scale -> { - contentScale = it.scale - } - - PlaybackAction.ShowDebug -> { - showDebugInfo = !showDebugInfo - } - - PlaybackAction.ShowPlaylist -> { - TODO() - } - - PlaybackAction.ShowVideoFilterDialog -> { - TODO() - } - - is PlaybackAction.ToggleAudio -> { - viewModel.changeAudioStream(it.index) - } - - is PlaybackAction.ToggleCaptions -> { - viewModel.changeSubtitleStream(it.index) - } - - PlaybackAction.SearchCaptions -> { - controllerViewState.hideControls() - viewModel.searchForSubtitles() - } - - PlaybackAction.Next -> { - // TODO focus is lost - viewModel.playNextUp() - } - - PlaybackAction.Previous -> { - val pos = player.currentPosition - if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) { - viewModel.playPrevious() - } else { - player.seekToPrevious() - } - } - } - } - - val showSegment = - !segmentCancelled && currentSegment != null && - nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L - BackHandler(showSegment) { - segmentCancelled = true - } - - Box( - modifier - .background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background), - ) { - val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f) - Box( - modifier = - Modifier - .fillMaxSize(playerSize) - .align(Alignment.TopCenter) - .onKeyEvent(keyHandler::onKeyEvent) - .focusRequester(focusRequester) - .focusable(), - ) { - var playerSize by remember { mutableStateOf(IntSize.Zero) } - PlayerSurface( - player = player, - surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - scaledModifier.onGloballyPositioned { - playerSize = it.size - }, - ) - if (presentationState.coverSurface) { - Box( - Modifier - .matchParentSize() - .background(Color.Black), - ) { - LoadingPage(focusEnabled = false) - } - } - - // If D-pad skipping, show the amount skipped in an animation - if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) { - SkipIndicator( - durationMs = skipIndicatorDuration, - onFinish = { - skipIndicatorDuration = 0L - }, - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 70.dp), - ) - // Show a small progress bar along the bottom of the screen - val showSkipProgress = true // TODO get from preferences - if (showSkipProgress) { - val percent = skipPosition.toFloat() / player.duration.toFloat() - Box( - modifier = - Modifier - .align(Alignment.BottomStart) - .background(MaterialTheme.colorScheme.border) - .clip(RectangleShape) - .height(3.dp) - .fillMaxWidth(percent), - ) - } - } - - // The playback controls - AnimatedVisibility( - controllerViewState.controlsVisible, - Modifier, - slideInVertically { it }, - slideOutVertically { it }, - ) { - PlaybackOverlay( - modifier = - Modifier - .padding(WindowInsets.systemBars.asPaddingValues()) - .fillMaxSize() - .background(Color.Transparent), - item = currentPlayback?.item, - playerControls = player, - controllerViewState = controllerViewState, - showPlay = playPauseState.showPlay, - previousEnabled = true, - nextEnabled = playlist.hasNext(), - seekEnabled = true, - seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, - seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, - skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, - onPlaybackActionClick = onPlaybackActionClick, - onClickPlaybackDialogType = { playbackDialog = it }, - onSeekBarChange = seekBarState::onValueChange, - showDebugInfo = showDebugInfo, - currentPlayback = currentPlayback, - chapters = mediaInfo?.chapters ?: listOf(), - trickplayInfo = mediaInfo?.trickPlayInfo, - trickplayUrlFor = viewModel::getTrickplayUrl, - playlist = playlist, - onClickPlaylist = { - viewModel.playItemInPlaylist(it) - }, - currentSegment = currentSegment, - showClock = preferences.appPreferences.interfacePreferences.showClock, - ) - } - - // Subtitles - if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { - val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) - AndroidView( - factory = { context -> - SubtitleView(context).apply { - preferences.appPreferences.interfacePreferences.subtitlesPreferences.let { - setStyle(it.toSubtitleStyle()) - setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) - setBottomPaddingFraction(it.margin.toFloat() / 100f) - } - viewModel.assHandler?.let { assHandler -> - if (prefs.overrides.directPlayAss) { - 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) - Media3SubtitleOverride( - preferences.appPreferences.interfacePreferences.subtitlesPreferences - .calculateEdgeSize(density), - ).apply(it) - it.children.firstOrNull { it is AssSubtitleView }?.let { - (it as? AssSubtitleView)?.apply { - Timber.v("Resize: $playerSize") - layoutParams = - FrameLayout - .LayoutParams( - playerSize.width, - playerSize.height, - ).apply { gravity = Gravity.CENTER } - } - } - }, - onReset = { - it.setCues(null) - }, - modifier = - Modifier - .fillMaxSize(maxSize) - .align(Alignment.TopCenter) - .background(Color.Transparent), - ) - } - } - - // Ask to skip intros, etc button - AnimatedVisibility( - showSegment, - modifier = - Modifier - .padding(40.dp) - .align(Alignment.BottomEnd), - ) { - currentSegment?.let { segment -> - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - delay(10.seconds) - segmentCancelled = true - } - TextButton( - stringRes = segment.type.skipStringRes, - onClick = { - segmentCancelled = true - player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) - }, - modifier = Modifier.focusRequester(focusRequester), - ) - } - } - - // Next up episode - BackHandler(nextUp != null) { - if (player.isPlaying) { - scope.launch(ExceptionHandler()) { - viewModel.cancelUpNextEpisode() - } - } else { - viewModel.navigationManager.goBack() - } - } - AnimatedVisibility( - nextUp != null, - modifier = - Modifier - .align(Alignment.BottomCenter), - ) { - nextUp?.let { - var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) } - var timeLeft by remember { - mutableLongStateOf( - preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds, - ) - } - BackHandler(timeLeft > 0 && autoPlayEnabled) { - timeLeft = -1 - autoPlayEnabled = false - } - if (autoPlayEnabled) { - LaunchedEffect(Unit) { - if (timeLeft == 0L) { - viewModel.playNextUp() - } else { - while (timeLeft > 0) { - delay(1.seconds) - timeLeft-- - } - if (timeLeft == 0L && autoPlayEnabled) { - viewModel.playNextUp() - } - } - } - } - NextUpEpisode( - title = - listOfNotNull( - it.data.seasonEpisode, - it.name, - ).joinToString(" - "), - description = it.data.overview, - imageUrl = LocalImageUrlService.current.rememberImageUrl(it), - aspectRatio = it.aspectRatio ?: AspectRatios.WIDE, - onClick = { - viewModel.reportInteraction() - controllerViewState.hideControls() - viewModel.playNextUp() - }, - timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, - modifier = - Modifier - .padding(8.dp) -// .height(128.dp) - .fillMaxHeight(1 - playerSize) - .fillMaxWidth(.66f) - .align(Alignment.BottomCenter) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), - shape = RoundedCornerShape(8.dp), - ), - ) - } - } - } - - subtitleSearch?.let { state -> - val wasPlaying = remember { player.isPlaying } - LaunchedEffect(Unit) { - player.pause() - } - val onDismissRequest = { - if (wasPlaying) { - player.play() - } - viewModel.cancelSubtitleSearch() - } - Dialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), - ) { - DownloadSubtitlesContent( - state = state, - language = subtitleSearchLanguage, - onSearch = { lang -> - viewModel.searchForSubtitles(lang) - }, - onClickDownload = { - viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying) - }, - onDismissRequest = onDismissRequest, - modifier = - Modifier - .widthIn(max = 640.dp) - .heightIn(max = 400.dp), - ) - } - } - - playbackDialog?.let { type -> - PlaybackDialog( - type = type, - settings = - PlaybackSettings( - showDebugInfo = showDebugInfo, - audioIndex = currentItemPlayback?.audioIndex, - audioStreams = mediaInfo?.audioStreams.orEmpty(), - subtitleIndex = currentItemPlayback?.subtitleIndex, - subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(), - playbackSpeed = playbackSpeed, - contentScale = contentScale, - subtitleDelay = subtitleDelay, - hasSubtitleDownloadPermission = - remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true }, - ), - onDismissRequest = { - playbackDialog = null - if (controllerViewState.controlsVisible) { - controllerViewState.pulseControls() - } - }, - onControllerInteraction = { - controllerViewState.pulseControls(Long.MAX_VALUE) - }, - onClickPlaybackDialogType = { - if (it == PlaybackDialogType.SUBTITLE_DELAY) { - // Hide controls so subtitles are fully visible - controllerViewState.hideControls() - } - playbackDialog = it - }, - onPlaybackActionClick = onPlaybackActionClick, - onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) }, - enableSubtitleDelay = player is MpvPlayer, - enableVideoScale = player !is MpvPlayer, + var playbackDialog by remember { mutableStateOf(null) } + LaunchedEffect(player) { + if (playerBackend == PlayerBackend.MPV) { + scope.launch(Dispatchers.IO + ExceptionHandler()) { + preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( + configuration, + density, ) } } } + + AmbientPlayerListener(player) + var contentScale by remember(playerBackend) { + mutableStateOf( + if (playerBackend == PlayerBackend.MPV) { + ContentScale.FillBounds + } else { + prefs.globalContentScale.scale + }, + ) + } + var playbackSpeed by remember { mutableFloatStateOf(1.0f) } + LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } + + val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO + LaunchedEffect(subtitleDelay) { + (player as? MpvPlayer)?.subtitleDelay = subtitleDelay + } + + val presentationState = rememberPresentationState(player, false) + val scaledModifier = + Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) + val focusRequester = remember { FocusRequester() } + val playPauseState = rememberPlayPauseButtonState(player) + val seekBarState = rememberSeekBarState(player, scope) + + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + val controllerViewState = remember { viewModel.controllerViewState } + + var skipIndicatorDuration by remember { mutableLongStateOf(0L) } + LaunchedEffect(controllerViewState.controlsVisible) { + // If controller shows/hides, immediately cancel the skip indicator + skipIndicatorDuration = 0L + } + var skipPosition by remember { mutableLongStateOf(0L) } + val updateSkipIndicator = { delta: Long -> + if ((skipIndicatorDuration > 0 && delta < 0) || (skipIndicatorDuration < 0 && delta > 0)) { + skipIndicatorDuration = 0 + } + skipIndicatorDuration += delta + skipPosition = player.currentPosition + } + val keyHandler = + PlaybackKeyHandler( + player = player, + controlsEnabled = nextUp == null, + skipWithLeftRight = true, + seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, + seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + controllerViewState = controllerViewState, + updateSkipIndicator = updateSkipIndicator, + skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, + onInteraction = viewModel::reportInteraction, + oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause, + onStop = { + player.stop() + viewModel.navigationManager.goBack() + }, + onPlaybackDialogTypeClick = { playbackDialog = it }, + ) + + val onPlaybackActionClick: (PlaybackAction) -> Unit = { + when (it) { + is PlaybackAction.PlaybackSpeed -> { + playbackSpeed = it.value + } + + is PlaybackAction.Scale -> { + contentScale = it.scale + } + + PlaybackAction.ShowDebug -> { + showDebugInfo = !showDebugInfo + } + + PlaybackAction.ShowPlaylist -> { + TODO() + } + + PlaybackAction.ShowVideoFilterDialog -> { + TODO() + } + + is PlaybackAction.ToggleAudio -> { + viewModel.changeAudioStream(it.index) + } + + is PlaybackAction.ToggleCaptions -> { + viewModel.changeSubtitleStream(it.index) + } + + PlaybackAction.SearchCaptions -> { + controllerViewState.hideControls() + viewModel.searchForSubtitles() + } + + PlaybackAction.Next -> { + // TODO focus is lost + viewModel.playNextUp() + } + + PlaybackAction.Previous -> { + val pos = player.currentPosition + if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) { + viewModel.playPrevious() + } else { + player.seekToPrevious() + } + } + } + } + + val showSegment = + !segmentCancelled && currentSegment != null && + nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L + BackHandler(showSegment) { + segmentCancelled = true + } + + Box( + modifier + .background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background), + ) { + val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f) + Box( + modifier = + Modifier + .fillMaxSize(playerSize) + .align(Alignment.TopCenter) + .onKeyEvent(keyHandler::onKeyEvent) + .focusRequester(focusRequester) + .focusable(), + ) { + var playerSize by remember { mutableStateOf(IntSize.Zero) } + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = + scaledModifier.onGloballyPositioned { + playerSize = it.size + }, + ) + if (presentationState.coverSurface) { + Box( + Modifier + .matchParentSize() + .background(Color.Black), + ) { + LoadingPage(focusEnabled = false) + } + } + + // If D-pad skipping, show the amount skipped in an animation + if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) { + SkipIndicator( + durationMs = skipIndicatorDuration, + onFinish = { + skipIndicatorDuration = 0L + }, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 70.dp), + ) + // Show a small progress bar along the bottom of the screen + val showSkipProgress = true // TODO get from preferences + if (showSkipProgress) { + val percent = skipPosition.toFloat() / player.duration.toFloat() + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .background(MaterialTheme.colorScheme.border) + .clip(RectangleShape) + .height(3.dp) + .fillMaxWidth(percent), + ) + } + } + + // The playback controls + AnimatedVisibility( + controllerViewState.controlsVisible, + Modifier, + slideInVertically { it }, + slideOutVertically { it }, + ) { + PlaybackOverlay( + modifier = + Modifier + .padding(WindowInsets.systemBars.asPaddingValues()) + .fillMaxSize() + .background(Color.Transparent), + item = currentPlayback?.item, + playerControls = player, + controllerViewState = controllerViewState, + showPlay = playPauseState.showPlay, + previousEnabled = true, + nextEnabled = playlist.hasNext(), + seekEnabled = true, + seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, + seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, + onPlaybackActionClick = onPlaybackActionClick, + onClickPlaybackDialogType = { playbackDialog = it }, + onSeekBarChange = seekBarState::onValueChange, + showDebugInfo = showDebugInfo, + currentPlayback = currentPlayback, + chapters = mediaInfo?.chapters ?: listOf(), + trickplayInfo = mediaInfo?.trickPlayInfo, + trickplayUrlFor = viewModel::getTrickplayUrl, + playlist = playlist, + onClickPlaylist = { + viewModel.playItemInPlaylist(it) + }, + currentSegment = currentSegment, + showClock = preferences.appPreferences.interfacePreferences.showClock, + ) + } + + // Subtitles + if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { + val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) + AndroidView( + factory = { context -> + SubtitleView(context).apply { + preferences.appPreferences.interfacePreferences.subtitlesPreferences.let { + setStyle(it.toSubtitleStyle()) + setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) + setBottomPaddingFraction(it.margin.toFloat() / 100f) + } + playerState.assHandler?.let { assHandler -> + if (prefs.overrides.directPlayAss) { + 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) + Media3SubtitleOverride( + preferences.appPreferences.interfacePreferences.subtitlesPreferences + .calculateEdgeSize(density), + ).apply(it) + it.children.firstOrNull { it is AssSubtitleView }?.let { + (it as? AssSubtitleView)?.apply { + Timber.v("Resize: $playerSize") + layoutParams = + FrameLayout + .LayoutParams( + playerSize.width, + playerSize.height, + ).apply { gravity = Gravity.CENTER } + } + } + }, + onReset = { + it.setCues(null) + }, + modifier = + Modifier + .fillMaxSize(maxSize) + .align(Alignment.TopCenter) + .background(Color.Transparent), + ) + } + } + + // Ask to skip intros, etc button + AnimatedVisibility( + showSegment, + modifier = + Modifier + .padding(40.dp) + .align(Alignment.BottomEnd), + ) { + currentSegment?.let { segment -> + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + delay(10.seconds) + segmentCancelled = true + } + TextButton( + stringRes = segment.type.skipStringRes, + onClick = { + segmentCancelled = true + player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) + }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + } + + // Next up episode + BackHandler(nextUp != null) { + if (player.isPlaying) { + scope.launch(ExceptionHandler()) { + viewModel.cancelUpNextEpisode() + } + } else { + viewModel.navigationManager.goBack() + } + } + AnimatedVisibility( + nextUp != null, + modifier = + Modifier + .align(Alignment.BottomCenter), + ) { + nextUp?.let { + var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) } + var timeLeft by remember { + mutableLongStateOf( + preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds, + ) + } + BackHandler(timeLeft > 0 && autoPlayEnabled) { + timeLeft = -1 + autoPlayEnabled = false + } + if (autoPlayEnabled) { + LaunchedEffect(Unit) { + if (timeLeft == 0L) { + viewModel.playNextUp() + } else { + while (timeLeft > 0) { + delay(1.seconds) + timeLeft-- + } + if (timeLeft == 0L && autoPlayEnabled) { + viewModel.playNextUp() + } + } + } + } + NextUpEpisode( + title = + listOfNotNull( + it.data.seasonEpisode, + it.name, + ).joinToString(" - "), + description = it.data.overview, + imageUrl = LocalImageUrlService.current.rememberImageUrl(it), + aspectRatio = it.aspectRatio ?: AspectRatios.WIDE, + onClick = { + viewModel.reportInteraction() + controllerViewState.hideControls() + viewModel.playNextUp() + }, + timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, + modifier = + Modifier + .padding(8.dp) +// .height(128.dp) + .fillMaxHeight(1 - playerSize) + .fillMaxWidth(.66f) + .align(Alignment.BottomCenter) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + shape = RoundedCornerShape(8.dp), + ), + ) + } + } + } + + subtitleSearch?.let { state -> + val wasPlaying = remember { player.isPlaying } + LaunchedEffect(Unit) { + player.pause() + } + val onDismissRequest = { + if (wasPlaying) { + player.play() + } + viewModel.cancelSubtitleSearch() + } + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + DownloadSubtitlesContent( + state = state, + language = subtitleSearchLanguage, + onSearch = { lang -> + viewModel.searchForSubtitles(lang) + }, + onClickDownload = { + viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying) + }, + onDismissRequest = onDismissRequest, + modifier = + Modifier + .widthIn(max = 640.dp) + .heightIn(max = 400.dp), + ) + } + } + + playbackDialog?.let { type -> + PlaybackDialog( + type = type, + settings = + PlaybackSettings( + showDebugInfo = showDebugInfo, + audioIndex = currentItemPlayback?.audioIndex, + audioStreams = mediaInfo?.audioStreams.orEmpty(), + subtitleIndex = currentItemPlayback?.subtitleIndex, + subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(), + playbackSpeed = playbackSpeed, + contentScale = contentScale, + subtitleDelay = subtitleDelay, + hasSubtitleDownloadPermission = + remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true }, + ), + onDismissRequest = { + playbackDialog = null + if (controllerViewState.controlsVisible) { + controllerViewState.pulseControls() + } + }, + onControllerInteraction = { + controllerViewState.pulseControls(Long.MAX_VALUE) + }, + onClickPlaybackDialogType = { + if (it == PlaybackDialogType.SUBTITLE_DELAY) { + // Hide controls so subtitles are fully visible + controllerViewState.hideControls() + } + playbackDialog = it + }, + onPlaybackActionClick = onPlaybackActionClick, + onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) }, + enableSubtitleDelay = player is MpvPlayer, + enableVideoScale = player !is MpvPlayer, + ) + } } 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 8d59c1f1..7e06f5ee 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 @@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination @@ -58,7 +59,6 @@ import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.checkForSupport @@ -66,6 +66,9 @@ import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.subtitleMimeTypes import com.github.damontecres.wholphin.util.supportItemKinds +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 io.github.peerless2012.ass.media.AssHandler @@ -101,13 +104,14 @@ import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaystateCommand import org.jellyfin.sdk.model.api.PlaystateMessage import org.jellyfin.sdk.model.api.TrickplayInfo +import org.jellyfin.sdk.model.api.VideoRange +import org.jellyfin.sdk.model.api.VideoRangeType import org.jellyfin.sdk.model.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.Date import java.util.UUID -import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -115,10 +119,10 @@ import kotlin.time.Duration.Companion.seconds /** * This [ViewModel] is responsible for playing media including moving through playlists (including next up episodes) */ -@HiltViewModel +@HiltViewModel(assistedFactory = PlaybackViewModel.Factory::class) @OptIn(markerClass = [UnstableApi::class]) class PlaybackViewModel - @Inject + @AssistedInject constructor( @param:ApplicationContext internal val context: Context, internal val api: ApiClient, @@ -133,15 +137,20 @@ class PlaybackViewModel private val deviceProfileService: DeviceProfileService, private val refreshRateService: RefreshRateService, val streamChoiceService: StreamChoiceService, + private val userPreferencesService: UserPreferencesService, + @Assisted private val destination: Destination, ) : ViewModel(), Player.Listener, AnalyticsListener { - var assHandler: AssHandler? = null - val player by lazy { - val creation = playerFactory.createVideoPlayer() - assHandler = creation.assHandler - creation.player + @AssistedFactory + interface Factory { + fun create(destination: Destination): PlaybackViewModel } + + val currentPlayer = MutableStateFlow(null) + + internal lateinit var player: Player + private var mediaSession: MediaSession? = null internal val mutex = Mutex() @@ -178,7 +187,14 @@ class PlaybackViewModel val currentUserDto = serverRepository.currentUserDto init { - addCloseable { + viewModelScope.launchIO { + addCloseable { disconnectPlayer() } + init() + } + } + + private fun disconnectPlayer() { + if (this@PlaybackViewModel::player.isInitialized) { player.removeListener(this@PlaybackViewModel) (player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel) @@ -186,26 +202,72 @@ class PlaybackViewModel it.release() player.removeListener(it) } - jobs.forEach { it.cancel() } player.release() mediaSession?.release() } - viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } + jobs.forEach { it.cancel() } + } + + private suspend fun createPlayer( + isHdr: Boolean, + is4k: Boolean, + ) { + val softwareDecoding = + !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding + val playerBackend = + when (preferences.appPreferences.playbackPreferences.playerBackend) { + PlayerBackend.UNRECOGNIZED, + PlayerBackend.EXO_PLAYER, + -> PlayerBackend.EXO_PLAYER + + PlayerBackend.MPV -> PlayerBackend.MPV + + PlayerBackend.PREFER_MPV -> if (isHdr || (is4k && softwareDecoding)) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV + } + + Timber.d("Selected backend: %s", playerBackend) + if (currentPlayer.value?.backend != playerBackend) { + Timber.i("Switching player backend to %s", playerBackend) + withContext(Dispatchers.Main) { + disconnectPlayer() + } + + val playerCreation = + playerFactory.createVideoPlayer( + playerBackend, + preferences.appPreferences.playbackPreferences, + ) + this.player = playerCreation.player + currentPlayer.update { + PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler) + } + configurePlayer() + } + } + + private fun configurePlayer() { player.addListener(this) (player as? ExoPlayer)?.addAnalyticsListener(this) jobs.add(subscribe()) jobs.add(listenForTranscodeReason()) + val sessionPlayer = + MediaSessionPlayer( + player, + controllerViewState, + preferences.appPreferences.playbackPreferences, + ) + mediaSession = + MediaSession + .Builder(context, sessionPlayer) + .build() } /** * Initialize from the UI to start playback */ - fun init( - destination: Destination, - preferences: UserPreferences, - ) { - nextUp.value = null - this.preferences = preferences + private suspend fun init() { + nextUp.setValueOnMain(null) + this.preferences = userPreferencesService.getCurrent() if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { addCloseable { refreshRateService.resetRefreshRate() } } @@ -238,82 +300,64 @@ class PlaybackViewModel } } this.itemId = itemId - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error preparing for playback for $itemId", - ), - ) { - val queriedItem = api.userLibraryApi.getItem(itemId).content - val base = - if (queriedItem.type.playable) { - queriedItem - } else if (destination is Destination.PlaybackList) { - isPlaylist = true - val playlistResult = - playlistCreator.createFrom( - item = queriedItem, - startIndex = destination.startIndex ?: 0, - sortAndDirection = destination.sortAndDirection, - shuffled = destination.shuffle, - recursive = destination.recursive, - filter = destination.filter, - ) - when (val r = playlistResult) { - is PlaylistCreationResult.Error -> { - loading.setValueOnMain(LoadingState.Error(r.message, r.ex)) - return@launch - } - - is PlaylistCreationResult.Success -> { - if (r.playlist.items.isEmpty()) { - showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) - navigationManager.goBack() - return@launch - } - withContext(Dispatchers.Main) { - this@PlaybackViewModel.playlist.value = r.playlist - } - r.playlist.items - .first() - .data - } + val queriedItem = api.userLibraryApi.getItem(itemId).content + val base = + if (queriedItem.type.playable) { + queriedItem + } else if (destination is Destination.PlaybackList) { + isPlaylist = true + val playlistResult = + playlistCreator.createFrom( + item = queriedItem, + startIndex = destination.startIndex ?: 0, + sortAndDirection = destination.sortAndDirection, + shuffled = destination.shuffle, + recursive = destination.recursive, + filter = destination.filter, + ) + when (val r = playlistResult) { + is PlaylistCreationResult.Error -> { + loading.setValueOnMain(LoadingState.Error(r.message, r.ex)) + return + } + + is PlaylistCreationResult.Success -> { + if (r.playlist.items.isEmpty()) { + showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) + navigationManager.goBack() + return + } + withContext(Dispatchers.Main) { + this@PlaybackViewModel.playlist.value = r.playlist + } + r.playlist.items + .first() + .data } - } else { - throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } - - val sessionPlayer = - MediaSessionPlayer( - player, - controllerViewState, - preferences.appPreferences.playbackPreferences, - ) - mediaSession = - MediaSession - .Builder(context, sessionPlayer) - .build() - - val item = BaseItem.from(base, api) - - val played = - play( - item, - positionMs, - itemPlayback, - forceTranscoding, - ) - if (!played) { - playNextUp() + } else { + throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } - if (!isPlaylist) { - val result = playlistCreator.createFrom(queriedItem) - if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { - withContext(Dispatchers.Main) { - this@PlaybackViewModel.playlist.value = result.playlist - } + viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } + + val item = BaseItem.from(base, api) + val played = + play( + item, + positionMs, + itemPlayback, + forceTranscoding, + ) + if (!played) { + playNextUp() + } + + if (!isPlaylist) { + val result = playlistCreator.createFrom(queriedItem) + if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { + withContext(Dispatchers.Main) { + this@PlaybackViewModel.playlist.value = result.playlist } } } @@ -384,6 +428,21 @@ class PlaybackViewModel return@withContext false } + val videoStream = + mediaSource.mediaStreams + ?.firstOrNull { it.type == MediaStreamType.VIDEO } + ?.let { + val isHdr = + it.videoRange == VideoRange.HDR || + (it.videoRangeType != VideoRangeType.SDR && it.videoRangeType != VideoRangeType.UNKNOWN) + // Often times 4k movies have a wider aspect ratio so the height is lower even though the width is still 3840 + val is4k = (it.width ?: 0) > 2560 || (it.height ?: 0) > 1440 + SimpleVideoStream(it.index, isHdr, is4k) + } + + // Create the correct player for the media + createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) + val subtitleStreams = mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } @@ -454,6 +513,8 @@ class PlaybackViewModel this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse updateCurrentMedia { CurrentMediaInfo( + sourceId = mediaSource.id, + videoStream = videoStream, audioStreams = audioStreams, subtitleStreams = subtitleStreams, chapters = chapters, @@ -493,71 +554,18 @@ class PlaybackViewModel enableDirectStream: Boolean = !this.forceTranscoding, ) = withContext(Dispatchers.IO) { val itemId = item.id - val playerBackend = preferences.appPreferences.playbackPreferences.playerBackend val currentPlayback = this@PlaybackViewModel.currentPlayback.value if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) { - // If direct playing, can try to switch tracks without playback restarting - // Except for external subtitles - // TODO there's probably no reason why we can't add external subtitles? - Timber.v("changeStreams direct play") - - val source = currentPlayback.mediaSourceInfo - val externalSubtitle = source.findExternalSubtitle(subtitleIndex) - - if (externalSubtitle == null) { - val result = - withContext(Dispatchers.Main) { - TrackSelectionUtils.createTrackSelections( - onMain { player.trackSelectionParameters }, - onMain { player.currentTracks }, - playerBackend, - true, - audioIndex, - subtitleIndex, - source, - ) - } - if (result.bothSelected) { - onMain { player.trackSelectionParameters = result.trackSelectionParameters } - // TODO lots of duplicate code in this block - Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex") - val itemPlayback = - currentItemPlayback.copy( - sourceId = source.id?.toUUIDOrNull(), - audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, - // Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index - subtitleIndex = - if (currentItemPlayback.subtitleIndex < 0) { - currentItemPlayback.subtitleIndex - } else { - subtitleIndex ?: TrackIndex.DISABLED - }, - ) - if (userInitiated) { - viewModelScope.launchIO { - Timber.v("Saving user initiated item playback: %s", itemPlayback) - val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback) - withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentItemPlayback.value = updated - } - } - } - withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentPlayback.update { - (it ?: currentPlayback).copy( - tracks = checkForSupport(player.currentTracks), - ) - } - - this@PlaybackViewModel.currentItemPlayback.value = itemPlayback - } - loadSubtitleDelay() - return@withContext - } - } else { - Timber.v("changeStreams direct play, external subtitle was requested") - } + val wasSuccessful = + changeStreamsDirectPlay( + currentPlayback = currentPlayback, + currentItemPlayback = currentItemPlayback, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + userInitiated = userInitiated, + ) + if (wasSuccessful) return@withContext } Timber.d( @@ -575,7 +583,7 @@ class PlaybackViewModel PlaybackInfoDto( startTimeTicks = null, deviceProfile = - if (playerBackend == PlayerBackend.EXO_PLAYER) { + if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) { deviceProfileService.getOrCreateDeviceProfile( preferences.appPreferences.playbackPreferences, serverRepository.currentServer.value?.serverVersion, @@ -680,7 +688,7 @@ class PlaybackViewModel CurrentPlayback( item = item, tracks = listOf(), - backend = preferences.appPreferences.playbackPreferences.playerBackend, + backend = currentPlayer.value!!.backend, playMethod = transcodeType, playSessionId = response.playSessionId, liveStreamId = source.liveStreamId, @@ -731,7 +739,7 @@ class PlaybackViewModel TrackSelectionUtils.createTrackSelections( player.trackSelectionParameters, player.currentTracks, - playerBackend, + currentPlayer.value!!.backend, source.supportsDirectPlay, audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, subtitleIndex, @@ -752,6 +760,81 @@ class PlaybackViewModel } } + /** + * If direct playing, can try to switch tracks without playback restarting + * Except for external subtitles + */ + @OptIn(UnstableApi::class) + private suspend fun changeStreamsDirectPlay( + currentPlayback: CurrentPlayback, + currentItemPlayback: ItemPlayback, + audioIndex: Int?, + subtitleIndex: Int?, + userInitiated: Boolean, + ): Boolean = + withContext(Dispatchers.IO) { + // TODO there's probably no reason why we can't add external subtitles? + Timber.v("changeStreams direct play") + + val source = currentPlayback.mediaSourceInfo + val externalSubtitle = source.findExternalSubtitle(subtitleIndex) + + if (externalSubtitle == null) { + val result = + withContext(Dispatchers.Main) { + TrackSelectionUtils.createTrackSelections( + onMain { player.trackSelectionParameters }, + onMain { player.currentTracks }, + currentPlayer.value!!.backend, + true, + audioIndex, + subtitleIndex, + source, + ) + } + if (result.bothSelected) { + onMain { player.trackSelectionParameters = result.trackSelectionParameters } + // TODO lots of duplicate code in this block + Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex") + val itemPlayback = + currentItemPlayback.copy( + sourceId = source.id?.toUUIDOrNull(), + audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, + // Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index + subtitleIndex = + if (currentItemPlayback.subtitleIndex < 0) { + currentItemPlayback.subtitleIndex + } else { + subtitleIndex ?: TrackIndex.DISABLED + }, + ) + if (userInitiated) { + viewModelScope.launchIO { + Timber.v("Saving user initiated item playback: %s", itemPlayback) + val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback) + withContext(Dispatchers.Main) { + this@PlaybackViewModel.currentItemPlayback.value = updated + } + } + } + withContext(Dispatchers.Main) { + this@PlaybackViewModel.currentPlayback.update { + (it ?: currentPlayback).copy( + tracks = checkForSupport(player.currentTracks), + ) + } + + this@PlaybackViewModel.currentItemPlayback.value = itemPlayback + } + loadSubtitleDelay() + return@withContext true + } + } else { + Timber.v("changeStreams direct play, external subtitle was requested") + } + return@withContext false + } + fun changeAudioStream(index: Int) { viewModelScope.launchIO { Timber.d("Changing audio track to %s", index) @@ -1118,9 +1201,7 @@ class PlaybackViewModel fun release() { Timber.v("release") - activityListener?.release() - player.release() - mediaSession?.release() + disconnectPlayer() activityListener = null } @@ -1296,3 +1377,9 @@ class PlaybackViewModel } } } + +data class PlayerState( + val player: Player, + val backend: PlayerBackend, + val assHandler: AssHandler?, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt index 885be814..6444a268 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt @@ -23,3 +23,9 @@ data class SimpleMediaStream( ) } } + +data class SimpleVideoStream( + val index: Int, + val hdr: Boolean, + val is4k: Boolean, +) 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 4c952a20..201b5982 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 @@ -164,7 +164,9 @@ object TrackSelectionUtils { } // TODO MPV could use literal indexes because they are stored in the track format ID - PlayerBackend.MPV -> { + PlayerBackend.PREFER_MPV, + PlayerBackend.MPV, + -> { when (type) { MediaStreamType.VIDEO -> { serverIndex - externalSubtitleCount + 1 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt index b9ceee25..9b9bcf9c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation @@ -37,6 +38,7 @@ import com.github.damontecres.wholphin.preferences.AppSwitchPreference 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.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch @@ -171,6 +173,8 @@ fun ComposablePreference( is AppChoicePreference -> { val values = stringArrayResource(preference.displayValues).toList() + val subtitles = + preference.subtitles?.let { stringArrayResource(preference.subtitles).toList() } val summary = preference.summary?.let { stringResource(it) } ?: preference.summary(context, value) @@ -188,24 +192,31 @@ fun ComposablePreference( fromLongClick = false, items = values.mapIndexed { index, it -> - if (index == selectedIndex) { - DialogItem( - text = it, - icon = Icons.Default.Done, - onClick = { - onValueChange(preference.indexToValue(index)) - dialogParams = null - }, - ) - } else { - DialogItem( - text = it, - onClick = { - onValueChange(preference.indexToValue(index)) - dialogParams = null - }, - ) - } + DialogItem( + headlineContent = { + Text(it) + }, + leadingContent = { + if (index == selectedIndex) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = "selected", + ) + } + }, + supportingContent = { + subtitles?.let { + val text = subtitles[index] + if (text.isNotNullOrBlank()) { + Text(text) + } + } + }, + onClick = { + onValueChange(preference.indexToValue(index)) + dialogParams = null + }, + ) }, ) }, 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 ba3d54da..f84d3b89 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,8 @@ enum class PreferenceScreenOption { ADVANCED, USER_INTERFACE, SUBTITLES, + EXO_PLAYER, + MPV, ; 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 95432283..8fedf3b6 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 @@ -43,9 +43,10 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.SingletonImageLoader import coil3.imageLoader import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +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.advancedPreferences import com.github.damontecres.wholphin.preferences.basicPreferences @@ -87,6 +88,7 @@ fun PreferencesContent( val state = rememberLazyListState() var preferences by remember { mutableStateOf(initialPreferences) } val currentUser by viewModel.currentUser.observeAsState() + val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) @@ -125,6 +127,8 @@ fun PreferencesContent( PreferenceScreenOption.ADVANCED -> advancedPreferences PreferenceScreenOption.USER_INTERFACE -> uiPreferences PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences + PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences + PreferenceScreenOption.MPV -> MpvPreferences } val screenTitle = when (preferenceScreenOption) { @@ -132,6 +136,8 @@ fun PreferencesContent( PreferenceScreenOption.ADVANCED -> R.string.advanced_settings PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style + PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options + PreferenceScreenOption.MPV -> R.string.mpv_options } var visible by remember { mutableStateOf(false) } @@ -476,7 +482,7 @@ fun PreferencesContent( SeerrDialogMode.Remove -> { ConfirmDialog( title = stringResource(R.string.remove_seerr_server), - body = "", + body = currentServer?.url ?: "", onCancel = { seerrDialogMode = SeerrDialogMode.None }, onConfirm = { seerrVm.removeServer() @@ -498,13 +504,7 @@ fun PreferencesContent( AddSeerServerDialog( currentUsername = currentUser?.name, status = status, - onSubmit = { url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod -> - if (method == SeerrAuthMethod.API_KEY) { - seerrVm.submitServer(url, passwordOrApiKey) - } else { - seerrVm.submitServer(url, username ?: "", passwordOrApiKey, method) - } - }, + onSubmit = seerrVm::submitServer, onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, ) } @@ -527,6 +527,8 @@ fun PreferencesPage( PreferenceScreenOption.BASIC, PreferenceScreenOption.ADVANCED, PreferenceScreenOption.USER_INTERFACE, + PreferenceScreenOption.EXO_PLAYER, + PreferenceScreenOption.MPV, -> { PreferencesContent( initialPreferences, 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 0d7d3c6e..b209ee59 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 @@ -181,9 +181,10 @@ fun ServerList( @Composable fun rememberIdColor( id: UUID?, + alpha: Float = 1f, nullColor: Color = MaterialTheme.colorScheme.surfaceVariant, ): Color = - remember(id) { + remember(id, alpha) { if (id == null) { return@remember nullColor } @@ -212,6 +213,7 @@ fun rememberIdColor( red = (r + m).coerceIn(0f, 1f), green = (g + m).coerceIn(0f, 1f), blue = (b + m).coerceIn(0f, 1f), + alpha = alpha, ) } 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 b71e0af0..53afb662 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 @@ -118,13 +118,11 @@ fun SwitchUserContent( users = users, currentUser = currentUser, onSwitchUser = { user -> - // TODO PIN-related -// if (user.pin.isNotNullOrBlank()) { -// switchUserWithPin = user -// } else { -// viewModel.switchUser(user) -// } - viewModel.switchUser(user) + if (user.hasPin) { + switchUserWithPin = user + } else { + viewModel.switchUser(user) + } }, onAddUser = { showAddUser = true }, onRemoveUser = { user -> 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 d8b1b096..1b35cce6 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 @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.setup import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -38,6 +39,7 @@ 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.unit.sp import androidx.compose.ui.window.DialogProperties import androidx.tv.material3.Border import androidx.tv.material3.Button @@ -51,10 +53,13 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.PreviewTvSpec 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.tryRequestFocus +import java.util.UUID /** * Display a list of users plus option to add a new one or switch servers @@ -285,6 +290,65 @@ private fun UserIconCard( } } +@Composable +fun UserIconCardImage( + id: UUID, + name: String?, + imageUrl: String?, + modifier: Modifier = Modifier, + alpha: Float = 1f, +) { + var imageError by remember { mutableStateOf(false) } + val userColor = rememberIdColor(id, alpha) + Box( + modifier = + modifier.background( + color = userColor, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (imageUrl.isNotNullOrBlank() && !imageError) { + AsyncImage( + model = imageUrl, + contentDescription = name, + contentScale = ContentScale.Crop, + onError = { imageError = true }, + modifier = + Modifier + .fillMaxSize() + .clip(CircleShape), + ) + } else { + val firstLetter = + remember(id, name) { + (name ?: id.toString()).firstOrNull()?.uppercase() ?: "?" + } + Text( + text = firstLetter, + style = MaterialTheme.typography.bodyLarge, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + } +} + +@PreviewTvSpec +@Composable +fun UserIconCardImagePreview() { + WholphinTheme { + UserIconCardImage( + id = UUID.randomUUID(), + name = "A smith", + imageUrl = null, + modifier = Modifier.size(24.dp), + ) + } +} + /** * Add User card component - displays a + icon in a circle */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index 0c74f715..d2b48c35 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -6,10 +6,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -30,6 +32,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -240,7 +243,7 @@ fun AddSeerrServerUsername( }, ), isInputValid = { true }, - modifier = Modifier.focusRequester(focusRequester), + modifier = Modifier.focusRequester(usernameFocusRequester), ) } Row( @@ -283,12 +286,23 @@ fun AddSeerrServerUsername( style = MaterialTheme.typography.titleLarge, ) } - TextButton( - stringRes = R.string.submit, + Button( onClick = { onSubmit.invoke(url, username, password) }, - enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && password.isNotNullOrBlank(), + enabled = + error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && + status != LoadingState.Loading, modifier = Modifier.align(Alignment.CenterHorizontally), - ) + ) { + if (status != LoadingState.Loading) { + Text(text = stringResource(R.string.submit)) + } else { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = + Modifier.size(24.dp), + ) + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt index 0feef21c..3601fdd7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -17,7 +17,7 @@ import com.github.damontecres.wholphin.util.LoadingState fun AddSeerServerDialog( currentUsername: String?, status: LoadingState, - onSubmit: (url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, + onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onDismissRequest: () -> Unit, ) { var authMethod by remember { mutableStateOf(null) } @@ -49,7 +49,7 @@ fun AddSeerServerDialog( ) { AddSeerrServerApiKey( onSubmit = { url, apiKey -> - onSubmit.invoke(url, null, apiKey, SeerrAuthMethod.API_KEY) + onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, status = status, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 0b067287..4f93217a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.setup.seerr +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException @@ -8,95 +9,96 @@ import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.launchIO +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 jakarta.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl import timber.log.Timber @HiltViewModel class SwitchSeerrViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, private val seerrServerRepository: SeerrServerRepository, private val seerrService: SeerrService, private val serverRepository: ServerRepository, ) : ViewModel() { val currentUser = serverRepository.currentUser + val currentSeerrServer = seerrServerRepository.currentServer val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) - private fun cleanUrl(url: String) = - if (!url.endsWith("/api/v1")) { - url - .toHttpUrlOrNull() - ?.newBuilder() - ?.apply { - addPathSegment("api") - addPathSegment("v1") - }?.build() - .toString() - } else { - url - } - - fun submitServer( - url: String, - apiKey: String, - ) { - viewModelScope.launchIO { - val url = cleanUrl(url) - val result = - try { - seerrServerRepository.testConnection( - authMethod = SeerrAuthMethod.API_KEY, - url = url, - username = null, - passwordOrApiKey = apiKey, - ) - } catch (ex: ClientException) { - Timber.w(ex, "Error logging in via API Key") - if (ex.statusCode == 401 || ex.statusCode == 403) { - LoadingState.Error("Invalid credentials", ex) - } else { - LoadingState.Error(ex) - } - } - if (result is LoadingState.Success) { - seerrServerRepository.addAndChangeServer(url, apiKey) - } - serverConnectionStatus.update { result } - } - } - fun submitServer( url: String, username: String, - password: String, + passwordOrApiKey: String, authMethod: SeerrAuthMethod, ) { viewModelScope.launchIO { - val url = cleanUrl(url) - val result = + serverConnectionStatus.update { LoadingState.Loading } + val urls = try { - seerrServerRepository.testConnection( - authMethod = authMethod, - url = url, - username = username, - passwordOrApiKey = password, - ) - } catch (ex: ClientException) { - Timber.w(ex, "Error logging in via %s", authMethod) - if (ex.statusCode == 401 || ex.statusCode == 403) { - LoadingState.Error("Invalid credentials", ex) - } else { + createUrls(url) + } catch (ex: IllegalArgumentException) { + showToast(context, "Invalid URL") + serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } + return@launchIO + } + var result: LoadingState = LoadingState.Error("No url") + for (url in urls) { + Timber.d("Trying %s", url) + result = + try { + seerrServerRepository.testConnection( + authMethod = authMethod, + url = url.toString(), + username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, + passwordOrApiKey = passwordOrApiKey, + ) + } catch (ex: ClientException) { + Timber.w(ex, "ClientException logging in") + if (ex.statusCode == 401 || ex.statusCode == 403) { + showToast(context, "Invalid credentials") + result = LoadingState.Error("Invalid credentials", ex) + break + } else { + LoadingState.Error("Could not connect with URL") + } + } catch (ex: Exception) { + Timber.w(ex, "Exception logging in") LoadingState.Error(ex) } + if (result is LoadingState.Success) { + when (authMethod) { + SeerrAuthMethod.LOCAL, + SeerrAuthMethod.JELLYFIN, + -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + authMethod, + username, + passwordOrApiKey, + ) + } + + SeerrAuthMethod.API_KEY -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + passwordOrApiKey, + ) + } + } + break } - if (result is LoadingState.Success) { - seerrServerRepository.addAndChangeServer(url, authMethod, username, password) + } + if (result is LoadingState.Error) { + showToast(context, "Error: ${result.message}") } serverConnectionStatus.update { result } } @@ -112,3 +114,39 @@ class SwitchSeerrViewModel serverConnectionStatus.update { LoadingState.Pending } } } + +fun createUrls(url: String): List { + val urls = mutableListOf() + if (url.startsWith("http://") || url.startsWith("https://")) { + urls.add(url) + val httpUrl = url.toHttpUrl() + if (HttpUrl.defaultPort(httpUrl.scheme) == httpUrl.port) { + urls.add("$url:5055") + } + } else { + urls.add("http://$url") + val httpUrl = "http://$url".toHttpUrl() + if (httpUrl.port == 80) { + urls.add("https://$url") + urls.add("http://$url:5055") + urls.add("https://$url:5055") + } else { + urls.add("https://$url") + } + } + return urls.map { cleanUrl(it).toHttpUrl() } +} + +private fun cleanUrl(url: String) = + if (!url.endsWith("/api/v1")) { + url + .toHttpUrl() + .newBuilder() + .apply { + addPathSegment("api") + addPathSegment("v1") + }.build() + .toString() + } else { + url + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt new file mode 100644 index 00000000..5358a14e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt @@ -0,0 +1,113 @@ +package com.github.damontecres.wholphin.ui.util + +import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter +import com.github.damontecres.wholphin.data.filter.DecadeFilter +import com.github.damontecres.wholphin.data.filter.FavoriteFilter +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.FilterVideoType +import com.github.damontecres.wholphin.data.filter.GenreFilter +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter +import com.github.damontecres.wholphin.data.filter.PlayedFilter +import com.github.damontecres.wholphin.data.filter.VideoTypeFilter +import com.github.damontecres.wholphin.data.filter.YearFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.genresApi +import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.api.client.extensions.yearsApi +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import timber.log.Timber +import java.util.TreeSet +import java.util.UUID + +object FilterUtils { + /** + * Gets the possible values for a filter + * + * For example, the possible genres in the parent ID + */ + suspend fun getFilterOptionValues( + api: ApiClient, + userId: UUID?, + parentId: UUID?, + filterOption: ItemFilterBy<*>, + ): List = + withContext(Dispatchers.IO) { + try { + when (filterOption) { + GenreFilter -> { + api.genresApi + .getGenres( + parentId = parentId, + userId = userId, + ).content.items + .map { FilterValueOption(it.name ?: "", it.id) } + } + + FavoriteFilter, + PlayedFilter, + -> { + listOf( + FilterValueOption("True", null), + FilterValueOption("False", null), + ) + } + + OfficialRatingFilter -> { + api.localizationApi.getParentalRatings().content.map { + FilterValueOption(it.name ?: "", it.value) + } + } + + VideoTypeFilter -> { + FilterVideoType.entries.map { + FilterValueOption(it.readable, it) + } + } + + YearFilter -> { + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNull { + it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } + } + } + + DecadeFilter -> { + val items = TreeSet() + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNullTo(items) { + it.name + ?.toIntOrNull() + ?.div(10) + ?.times(10) + } + items.toList().sorted().map { FilterValueOption("$it's", it) } + } + + CommunityRatingFilter -> { + (1..10).map { + FilterValueOption("$it", it) + } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Exception get filter value options for $filterOption") + listOf() + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt index ec634ab3..d07f86ff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt @@ -8,8 +8,10 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import com.github.damontecres.wholphin.ui.TimeFormatter +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext import java.time.LocalDateTime val LocalClock = compositionLocalOf { Clock() } @@ -32,12 +34,14 @@ data class Clock( fun ProvideLocalClock(content: @Composable () -> Unit) { val clock = remember { Clock() } LaunchedEffect(Unit) { - while (isActive) { - val now = LocalDateTime.now() - val time = TimeFormatter.format(now) - clock.now.value = now - clock.timeString.value = time - delay(2_000) + withContext(Dispatchers.IO) { + while (isActive) { + val now = LocalDateTime.now() + val time = TimeFormatter.format(now) + clock.now.value = now + clock.timeString.value = time + delay(2_000) + } } } CompositionLocalProvider(LocalClock provides clock, content) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt index e5b902d7..fad62f2b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt @@ -39,17 +39,9 @@ object MPVLib { external fun create(appctx: Context) - fun initialize() { - synchronized(this) { init() } - } + external fun init() - private external fun init() - - fun tearDown() { - synchronized(this) { destroy() } - } - - private external fun destroy() + external fun destroy() external fun attachSurface(surface: Surface) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt index 58c976c4..77613be3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt @@ -74,6 +74,8 @@ class MpvPlayer( SurfaceHolder.Callback { companion object { private const val DEBUG = false + + private val initLock = Any() } private var surface: Surface? = null @@ -131,7 +133,7 @@ class MpvPlayer( MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") Timber.v("Initializing MPVLib") - MPVLib.initialize() + MPVLib.init() MPVLib.setOptionString("force-window", "no") MPVLib.setOptionString("idle", "yes") @@ -1086,14 +1088,18 @@ class MpvPlayer( } MpvCommand.INITIALIZE -> { - init() + synchronized(initLock) { + init() + } } MpvCommand.DESTROY -> { - clearVideoSurfaceView(null) - MPVLib.removeLogObserver(mpvLogger) - MPVLib.tearDown() - Timber.d("MPVLib destroyed") + synchronized(initLock) { + MPVLib.setPropertyBoolean("pause", true) + MPVLib.removeLogObserver(mpvLogger) + MPVLib.destroy() + Timber.d("MPVLib destroyed") + } } } } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 0710b51d..3c7f38ed 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -32,6 +32,7 @@ enum MediaExtensionStatus{ enum PlayerBackend{ EXO_PLAYER = 0; MPV = 1; + PREFER_MPV = 2; } message MpvOptions{ diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml new file mode 100644 index 00000000..4ed1e15c --- /dev/null +++ b/app/src/main/res/values-cs/strings.xml @@ -0,0 +1,456 @@ + + + O aplikaci + Aktivní nahrávání + Oblíbené + Přidat server + Přidat uživatele + Zvuk + Místo narození + Bitrate + Narozen + Zrušit nahrávání + Zrušit nahrávání seriálu + Zrušit + Kapitoly + Vybrat %1$s + Vymazat mezipaměť obrázků + Sbírka + Kolekce + Hodnocení Komunity + Došlo k chybě! Stiskněte tlačítko pro odeslání protokolů na server. + Potvrdit + Pokračovat ve sledování + Hodnocení kritiků + %.1f sekund + Výchozí + Smazat + Zemřel + Režie %1$s + Režisér + Vypnuto + Nalezené Servery + Stahování… + Zapnuto + Konec v %1$s + Vložte Server IP nebo URL + Vložte adresu serveru + Epizody + Chyba při načítání kolekce %1$s + Externí + Oblíbený + Velikost + Nucené + Žánry + Přejít do + Skrýt ovládací prvky přehrávání + Přejít na sérii + Skrýt informace o ladění + Skrýt + Domů + Úvod + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + Knihovna + Informace o licenci + Živá TV + Načítání… + Označit celou sérii jako zhlédnutou? + Označit celou sérii jako nezhlédnuté? + Označit jako nezhlédnuté + Označit jako zhlédnuté + Maximální bitrate + Více podobných + Více + + Filmy + + + + + Jméno + Další v pořadí + Žádná data + Žádné výsledky + Nenalezeny žádné servery + Žádné naplánované nahrávání + Žádné aktualizace nejsou k dispozici + Žádný + Pouze vynucené titulky + Závěr + Cesta + + Lidé + + + + + Počet přehrání + Přehrát odtud + Přehrát + Zobrazit informace o ladění přehrávání + Rychlost přehrávání + Přehrávání + Seznam + Seznamy + Náhled + Nastavení uživatelského profilu + Fronta + Shrnout + Nedávno přidáno v %1$s + Nedávno přidáno + Nedávno nahráno + Nedávno vydané + Doporučeno + Nahrát Program + Nahrát Seriál + Odebrat z oblíbených + Restart + Pokračovat + Uložit + Hledat + Vyhledávání… + Hlasové vyhledávání + Začátek + Hledat hlasem + Zpracování + Zrušíte stisknutím tlačítka zpět + Chyba nahrávání zvuku + Chyba rozpoznávání hlasu + Vyžadováno oprávnění k přístupu k mikrofonu + Chyba sítě + Časový limit sítě + Řeč nerozpoznaná + Rozpoznávání hlasu je zaneprázdněno + Chyba serveru + Nebyla detekována žádná řeč + Neznámá chyba + Nepodařilo se spustit rozpoznávání hlasu + Časový limit pro rozpoznávání hlasu vypršel + Zkusit znovu + Vybrat server + Vybrat Uživatele + Zobrazit informace o ladění + Zobrazit další + Ukázat + Náhodně + Přeskočit + Datum přidání + Datum přidání epizody + Datum přehraní + Datum vydání + Jméno + Náhodný + Studia + Odeslat + Stahování trvá dlouho, možná budete muset restartovat přehrávání + Podnázev + Titulky + Návrhy + Přepnout servery + Přepnout + Nejlépe hodnocené Nezhlédnuté + Upoutávka + + Upoutávky + + + + + Harmonogram nahrávání + Televizní program + Sezóna + Sezóny + + Seriály + + + + + Prostředí + Neznámý + Aktualizace + Verze + Měřítko videa + Video + Videa + Sledujte živě + %1$d let starý + Přehraj s překódováním + Informace o médiu + Zobrazit hodiny + Pořadí odvysílaných epizod + Vytvořit nový playlist + Přidat do playlistu + Pozastavení jedním kliknutím + Stisknutím středu D-Padu pozastavíte/přehrajete + Kurzíva + Font + Pozadí + Úspěch + Rodičovské hodnocení + Doplňky + + Jiný + + + + + + V zákulisí + + + + + + Znělky + + + + + + Tématická videa + + + + + + Klipy + + + + + + Vymazané scény + + + + + + Rozhovory + + + + + + Scény + + + + + + Krátkometrážní filmy + + + + + + %d hodina + %d hodin + %d hodin + %d hodin + + + %d položka + %d položky + %d položek + %d položek + + + %d sekunda + %d sekundy + %d sekund + %d sekund + + Zařízení podporuje AC3/Dolby Digital + Pokročilá Nastavení + Pokročilé UI + Vzhled Aplikace + Automaticky kontrolovat aktualizace + Zpoždění před dalším přehráním + Další přehrát automaticky + Zkontrolovat aktualizace + Platí pouze pro Seriály + Vždy downmixovat do stereo formátu + Použijte modul dekodéru FFmpeg + Výchozí měřítko obsahu + Nainstalovat aktualizaci + Nainstalovaná verze + Maximální počet položek v řádcích domovské stránky + Vyberte výchozí položky, které se mají zobrazit, ostatní budou skryty + Přizpůsobení položek navigačního panelu + Kliknutím přepnete stránky + Přepnout stránky navigačního panelu při zaostření + Ochrana proti výpadku + Přehrát tematickou hudbu + Přepsání přehrávání + Zapamatovat vybrané karty + Povolit opakované sledování v dalším videu + Kroky Seek baru + Užitečné pro ladění + Odeslat protokoly aplikací na aktuální server + Pokusím se odeslat na poslední připojený server + Odesílat zprávy o selhání + Nastavení + Přeskočit zpět při obnovení přehrávání + Přeskočit zpět + Přeskakovat reklamy + Přeskočit vpřed + Chování přeskočení úvodu + Přeskočit chování konce + Chování přeskočení náhledů + Aktualizace k dispozici + URL adresa používaná ke kontrole aktualizací aplikace + Aktualizovat URL + Velikost písma + Barva písma + Průhlednost písma + Styl okraje + Barva okraje + Průhlednost pozadí + Styl pozadí + Styl titulků + Barva pozadí + Reset + Tučné písmo + MPV: Použití hardwarového dekódování + Zakažte, pokud dojde k pádům + Možnosti MPV + Možnosti ExoPlayeru + Přeskočit segment + Přeskočit reklamy + Přeskočit náhled + Přeskočit shrnutí + Chování přeskočení shrnutí + Přeskočit konec + Přeskočit úvod + Přehráno + Filtr + Rok + Desetiletí + Odstranit + DolbyVision + Atmos + MPV: Použití gpu-next + Upravit mpv.conf + Zahodit změny? + Okraj + Podrobné protokolování + Zadejte PIN + Automatické přihlášení + Přihlášení přes server + Stiskněte středové tlačítko pro potvrzení + Vyžadovat PIN pro profil + Potvrdit PIN + Nesprávný + PIN musí mít 4 znaky nebo více + PIN bude odstraněn + Velikost mezipaměti (MB) + Možnosti zobrazení + Sloupce + Mezery + Poměr stran + Zobrazit podrobnosti + Typ obrázku + Hostující hvězdy + Velikost okraje + Přepínání obnovovací frekvence + Automaticky + Použijte uživatelské jméno/heslo + Přihlášení + Obecné + Kontejner + Titul + Kodek + Profil + Úroveň + Rozlišení + Snímková frekvence + Bitová hloubka + Rozsah videa + Typ rozsahu videa + Barevný prostor + Přenos barev + Základní barvy + Formát pixelů + Referenční snímky + NAL + Jazyk + Rozložení + Kanály + Vzorkovací frekvence + AVC + Ano + Ne + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Zobrazit názvy + Opakovat + Zobrazit oblíbené kanály jako první + Řadit kanály podle naposledy sledovaných + Zpoždění titulků + Styl prospektu + Přepínání rozlišení + Místní + Přehrát upoutávku + Žádné upoutávky + Vyčistit výběr skladeb + DTS:X + DTS:HD + True HD + DD + DD+ + DTS + Direct play Dolby Vision Profil 7 + Ignoruj kontrolu kompatibility zařízení + Najít + Žádost + Čeká na vyřízení + Seerr integrace + Odstranit Seerr Server + Seerr server přidán + Heslo + Uživatelské jméno + URL + Nadcházející filmy + Trendy + Nadcházející TV pořady + Žádost ve 4K + Softwarové dekódování AV1 + Komerční + + Bezprostředně + + Trvání + + Vzorky + + + + + + Krátká videa + + + + + + %s Stažen + %s Staženo + %s Stažení + %s Stažení + + + Direct play ASS titulky + Direct play PGS titulky + Přehrávání Backendu + + Anamorfní + Prokládané + Color code programs + MPV je nyní výchozím přehrávačem mimo HDR.\nToto můžete změnit v nastavení. + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 23dca91e..03038fc8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2,7 +2,7 @@ Über diese App Aktive Aufnahmen - Favoriten + Zu Favoriten hinzufügen Server hinzufügen Benutzer hinzufügen Ton @@ -56,7 +56,7 @@ Serien Oberfläche Unbekannt - Updates + Aktualisierungen Version Live ansehen %1$d Jahre alt @@ -81,12 +81,12 @@ Automatisch auf Aktualisierungen prüfen Auf Aktualisierungen prüfen Einstellungen - Wählen %1$s + %1$s Wählen Bilder Cache leeren Community Bewertung Es ist ein Fehler aufgetreten! Drücke den Knopf um Logs zu deinem Server zu senden. Regie von %1$s - Deaktivert + Deaktiviert Gefundene Server Herunterladen… Aktiviert @@ -118,7 +118,7 @@ Als nächstes Keine Von hier wiedergeben - Ansehen + Wiedergeben Wiedergabegeschwindigkeit Wiedergabe Vorschau @@ -143,7 +143,7 @@ Der Download benötigt eine längere Zeit, evtl. musst du die Wiedergabe erneut starten Untertitel Vorschläge - Wechsel + Wechseln Top Bewertet Ungesehen Fernsehprogramm Mit Transkodierung abspielen @@ -207,12 +207,12 @@ - Clips - + Clip + Clips - Interviews - + Interview + Interviews Beispiele @@ -321,4 +321,67 @@ Nein Titel anzeigen Wiederholen + Generell + Keine Trailer + Trailer abspielen + Lokal + detaillierte Protokollierung + Login via Server + Drücke die mittlere Taste zur Bestätigung + Größe des Festplatten-Caches für Bilder (MB) + Besetzung & Mitwirkende + Container + Codec + Level + Bittiefe + AVC + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Nur erzwungene Untertitel + Beginne + verarbeite + Mikrofonzugriff erforderlich + Netzwerkfehler + Netzwerk Zeitüberschreitung + Serverfehler + unbekannter Fehler + wiederholen + 🇩🇪 Layout + Abtastrate + DTS + Entdecke + Anfrage + Ausstehend + Seerr Integration + Seerr server hinzugefügt + Passwort + Benutzername + URL + In 4K anfragen + DTS:X + DTS:HD + TrueHD + DD + DD+ + Im Trend + Starten der Spracherkennung fehlgeschlagen + Zurück wählen um abzubrechen + MPV ist jetzt der Standard-Player, außer für HDR.\nDas kannst du in den Einstellungen ändern. + Sprachsuche + Sprechen, um zu suchen + Ignoriert geräte kompatibilität + kommende Filme + kommende Serien + Direktes abspielen von Dolby Vision Profil 7 + Untertitelverzögerung + Sortiere Kanäle nach zuletzt geschaut + Favoriten als erstes anzeigen + Hintergrundstil + Bildwiederholraten-Anpassung + Keine Spracheingabe erkannt diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ce48243d..35d7e885 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -179,9 +179,9 @@ - Clips - - + Clip + Clips + Escenas eliminadas @@ -375,4 +375,50 @@ Programas codificados por colores Retardo de subtítulos Estilo del fondo + Solo Subtítulos Forzados + Búsqueda por voz + Iniciando + Habla para buscar + Procesando + Pulse atrás para cancelar + Error en la grabación de audio + Error de reconocimiento de voz + Se requiere permiso para el micrófono + Error de red + Tiempo de espera agotado de red + No se reconoce el habla + Reconocimiento de voz ocupado + Error de servidor + No se detecta voz + Error desconocido + No se pudo iniciar el reconocimiento de voz + Se agotó el tiempo de espera para el reconocimiento de voz + Reintentar + Cambio de resolución + Local + Reproducir trailer + Sin trailers + Opciones de pista claras + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Reproducción directa Dolby Vision Profile 7 + Ignora las comprobaciones de compatibilidad del dispositivo + Descubrir + Solicitar + Pendiente + Integración con Seerr + Eliminar servidor Seerr + Servidor Seerr añadido + Contraseña + Usuario + URL + Tendencias + Próximos estrenos + Próximos programas de televisión + Solicitar en 4K + Decodificación por software AV1 diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 4809541d..765c5fe2 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -359,4 +359,51 @@ Ühtegi serverit ei leidu Tausta dekoratsioon Lõppeb kell %1$s + Resolutsiooni vahetamine + Kohalik + Esita treilerit + Treilerit pole + Eemalda rajavalikud + Vaid sundkorras kuvatud subtiitrid + DTS:X + DTS:HD + TrueHD + DD + DD+ + Dolby Vision Profile 7 otseesitusega + Eirab seadme ühilduvuskontrolle + DTS + Otsi ja leia + Päring + Ootel + Seerri lõiming + Eemalda Seerri server + Salasõna + Kasutajanimi + Võrguaadress + Populaarsust koguv + Lähiajal avaldatavad filmid + Lähiajal avaldatavad telesarjad ja -saated + Küsi 4K versiooni + Seerri server on lisatud + Häälotsing + Alustan + Otsimiseks kõnele + Töötlen + Katkestamiseks vajuta Tagasi-nuppu + Heli salvestamise viga + Kõnetuvastamise viga + Vajalik on õigus kasutada mikrofoni + Võrguühenduse viga + Võrguühenduse aegumine + Pole midagi kõneldut, mida tuvastada + Kõnetuvastus on hõivatud + Serveri viga + Pole tuvastatavat kõnelemist + Tundmatu viga + Kõnetuvastuse käivitamine ei õnnestunud + Kõnetuvastus aegus + Proovi uuesti + AV1 tarkvaraline dekodeerimine + MPV on nüüd vaikimisi meediaesitaja kõige v.a. HDR-i jaoks.\nSeda saad muuta seadistustest. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 539ca50c..a53c580f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -11,7 +11,7 @@ Supprimer Réalisateur Réalisé par %1$s - Episodes + Épisodes Enregistrements en cours Lieu de naissance Né(e) @@ -197,7 +197,7 @@ Échelle de contenu par défaut Installer la mise à jour Version installée - Nombre d’éléments sur les lignes de la page d\'accueil + Nombre maximal d\'éléments par ligne sur la page d\'accueil Choisissez les éléments à afficher par défaut, les autres seront masqués Personnaliser les éléments du menu de navigation Cliquez pour changer de page @@ -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 @@ -375,4 +375,51 @@ Aucun serveur trouvé Décalage des sous-titres Style d\'arrière-plan + Effacer les choix de piste + Changement de résolution + Local + Lire la bande-annonce + Pas de bandes-annonces + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Sous-titres forcés uniquement + Lecture directe Dolby Vision Profile 7 + Ignore les vérifications de compatibilité des appareils + Recherche vocale + Démarrage + Parler pour rechercher + Traitement + Appuyez sur Retour pour annuler + Erreur d\'enregistrement audio + Erreur de reconnaissance vocale + Autorisation du microphone requise + Erreur réseau + Délai d\'expiration du réseau + Aucune voix reconnue + Reconnaissance vocale occupée + Erreur serveur + Aucune voix détectée + Erreur inconnue + Échec du démarrage de la reconnaissance vocale + Délai d\'attente de la reconnaissance vocale dépassé + Réessayer + Découvrir + Demande + En attente + Intégration Seerr + Supprimer le serveur Seerr + Serveur Seerr ajouté + Mot de passe + Nom d\'utilisateur + URL + Tendances + Films à venir + Séries à venir + Demande en 4K + Décodage logiciel AV1 + MPV est désormais le lecteur par défaut, sauf pour HDR.\nVous pouvez modifier cela dans les paramètres. diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index e9c73c87..2e02fb53 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -11,7 +11,7 @@ Mégse Fejezetek %1$s kiválasztása - Képgyorsítótár törlése + Kép-gyorsítótár törlése Gyűjtemény Gyűjtemények Közösségi értékelés @@ -233,7 +233,7 @@ Tartalom alapértelmezett méretezése Frissítés telepítése Telepített verzió - Maximális elemek soronként a kezdőlapon + Elemek száma soronként a kezdőlapon Válassza ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve Navigációs menü elemeinek testreszabása Kattintson az oldalak közti váltáshoz @@ -279,7 +279,7 @@ Összefoglaló átugrása Stáblista átugrása Intró átugrása - Lejátszott + Megnézett Szűrő Év Évtized @@ -355,8 +355,20 @@ Adja meg a szerver címét Lejátszás backend - Kép gyorsítótár méret (MB) + Kép-gyorsítótár mérete (MB) Navigációs menü oldalainak váltása fókusznál Lejátszás felülbírálatai + Felbontásváltás + Előzetes lejátszása + Nincsenek előzetesek + Helyi + DTS:X + DTS-HD + TrueHD + DD + DD+ + DTS + Csak kiegészítő felirat + Sávválasztás törlése diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 456bee92..c1bf5242 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -121,9 +121,9 @@ Ganti Server Ganti Terbaik Belum Ditonton - Cuplikan + Trailer - Cuplikan + Trailer Jadwal DVR Panduan @@ -291,11 +291,11 @@ Pemeran Tamu Anamorfik Interlace - Framerate - Kedalaman Bit - Rentang Video + Laju frame + Kedalaman bit + Rentang video Jenis rentang video - Color space + Ruang warna Format piksel NAL Bahasa @@ -336,4 +336,57 @@ Masukkan alamat server Server tidak ditemukan Penundaan subtitel + Penelusuran suara + Memulai + Ucapkan untuk mencari + Memproses + Tekan kembali untuk membatalkan + Error perekaman audio + Error pengenalan suara + Diperlukan izin akses mikrofon + Kesalahan jaringan + Koneksi jaringan terputus + Suara tidak dikenali + Pengenalan suara sedang memproses + Gangguan server + Tidak ada ucapan terdeteksi + Terjadi kesalahan yang tidak diketahui + Gagal memulai pengenalan suara + Batas waktu pengenalan suara habis + Coba lagi + Hanya Forced Subtitel + Peralihan refresh rate + Transfer warna + Primari warna + Frame referensi + Tata letak + Saluran + Gaya backdrop + Peralihan resolusi + Lokal + Putar trailer + Tidak ada trailer + Hapus pilihan trek + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Putar langsung Dolby Vision Profil 7 + Abaikan pemeriksaan kompatibilitas perangkat + Jelajahi + Integrasi Seerr + Hapus Server Seerr + Server Seerr ditambahkan + Kata sandi + Nama pengguna + URL + Tren + Film Mendatang + Acara TV Mendatang + Minta dalam 4K + Minta + Tertunda + Dekode perangkat lunak AV1 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c8837cdf..15a70f3d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -375,4 +375,51 @@ Nessun server trovato Stile backdrop Termina alle %1$s + Cambio risoluzione + Locale + Riproduci trailer + Nessun trailer + Cancella scelte tracce + Solo sottotitoli forzati + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Riproduzione diretta Dolby Vision Profilo 7 + Ignora le verifiche di compatibilità del dispositivo + Scopri + Richiedi + In attesa + Integrazione Seerr + Rimuovi server Seerr + Password + Nome utente + URL + In tendenza + Film in arrivo + Serie TV in arrivo + Richiedi in 4K + Server Seerr aggiunto + Ricerca vocale + Avvio in corso + Parla per cercare + Elaborazione in corso + Premi indietro per annullare + Errore registrazione audio + Errore riconoscimento vocale + Autorizzazione microfono richiesta + Errore di rete + Timeout di rete + Nessun discorso riconosciuto + Riconoscimento vocale occupato + Errore server + Nessun discorso rilevato + Errore sconosciuto + Impossibile avviare il riconoscimento vocale + Riprova + Riconoscimento vocale scaduto + Decodifica software AV1 + MPV è ora il lettore predefinito, eccetto per i contenuti HDR.\nPuoi modificarlo nelle impostazioni. diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 0583052c..5b39797f 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -1,4 +1,367 @@ Om + Aktive opptak + Legg til favoritt + Legg til server + Legg til bruker + Lyd + Fødested + Bitrate + Født + Avbryt opptak + Avbryt serieopptak + Avbryt + Kapitler + Velg %1$s + Tøm bildebuffer + Samling + Samlinger + Reklame + Brukervurdering + En feil har oppstått! Trykk på knappen for å sende logger til serveren din. + Bekreft + Fortsett å se + Kritikervurdering + %.1f sekunder + Regissør + Deaktivert + Oppdagede servere + + Laster ned… + Aktivert + Oppgi server-IP eller URL + Oppgi serveradresse + Episoder + Feil ved lasting av samling %1$s + Ekstern + Favoritter + Størrelse + Tvungen + Sjangere + Gå til serie + Gå til + Skjul avspillingskontroller + Skjul feilsøkingsinfo + Skjul + Hjem + Umiddelbart + Intro + #ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ + Bibliotek + Lisensinformasjon + Direkte-TV + Laster… + Marker hele serien som sett? + Marker hele serien som usett? + Marker som usett + Marker som sett + Maks bitrate + Mer som dette + Mer + Filmer + Navn + Neste + Ingen data + Ingen resultater + Ingen servere funnet + Ingen planlagte opptak + Ingen oppdatering tilgjengelig + Ingen + Outro + Filbane + Personer + Antall avspillinger + Spill av herfra + Spill av + Vis feilsøkingsinfo for avspilling + Avspillingshastighet + Avspilling + Spilleliste + Spillelister + Forhåndsvisning + Innstillinger for brukerprofil + + Sammendrag + Sist lagt til i %1$s + Sist lagt til + Sist tatt opp + Sist utgitt + Anbefalt + Ta opp program + Ta opp serie + Fjern favoritt + Start på nytt + Fortsett + Lagre + + Søk + Søker… + Velg server + Velg bruker + Vis feilsøkingsinfo + Vis \'Neste\' + Serie + Tilfeldig rekkefølge + Hopp over + Dato lagt til + Dato episode lagt til + Dato avspilt + Utgivelsesdato + Navn + Tilfeldig + Studioer + Send inn + Nedlastingen tar lang tid, du må kanskje starte avspillingen på nytt + Undertekst + Undertekster + Forslag + Bytt server + Bytt bruker + Best vurderte usette + Trailer + + Trailere + + + DVR-plan + Programoversikt + Sesong + Sesonger + TV-serier + Grensesnitt + Ukjent + Oppdateringer + Versjon + Videoskalering + Video + Videoer + Se direkte + %1$d år gammel + Spill av med transkoding + Mediainformasjon + Vis klokke + Rekkefølge etter sendedato + Lag ny spilleliste + Legg til i spilleliste + Pause med ett klikk + Trykk i midten på styreputen for å pause/spille av + Kursiv skrift + Skrifttype + Bakgrunn + Fullført + Aldersgrense + Spilletid + Ekstramateriale + + Annet + + + + Bak kulissene + + + + Temasanger + + + + Temavideoer + + + + Klipp + + + + Slettede scener + + + + Intervjuer + + + + Scener + + + + Smakebiter + + + + Bakomfilmer + + + + Kortfilmer + + + + %s nedlasting + %s nedlastinger + + + %d time + %d timer + + + %d element + %d elementer + + + %d sekund + %d sekunder + + Avanserte innstillinger + Avansert grensesnitt + App-tema + Sjekk automatisk etter oppdateringer + Forsinkelse før neste spilles av + Spill neste automatisk + Sjekk etter oppdateringer + Gjelder kun TV-serier + + Direkteavspilling av ASS-undertekster + Direkteavspilling av PGS-undertekster + Alltid nedmiks til stereo + Bruk FFmpeg-dekodermodul + Standard innholdsskalering + Installer oppdatering + Installert versjon + Maks elementer på rader på hjemskjermen + Velg standardelementer som skal vises; andre skjules + Tilpass elementer i navigasjonsskuffen + Klikk for å bytte side + Bytt side i navigasjonsskuffen ved fokus + Søvnbeskyttelse + Spill temamusikk + Overstyring av avspilling + Husk valgte faner + Tillat gjensyn i \'Neste\' + Steglengde for søkelinje + Nyttig for feilsøking + Send app-logger til gjeldende server + Vil prøve å sende til sist tilkoblede server + Send krasjrapporter + Innstillinger + Hopp tilbake ved gjenopptakelse av avspilling + Hopp tilbake + Handling for hopping over reklame + Hopp fremover + Handling for hopping over intro + Handling for hopping over outro + Handling for hopping over forhåndsvisning + Handling for hopping over sammendrag + Oppdatering tilgjengelig + URL for sjekk av app-oppdateringer + URL for oppdatering + Skriftstørrelse + Skriftfarge + Gjennomsiktighet for skrift + Kantstil + Kantfarge + Gjennomsiktighet for bakgrunn + Bakgrunnsstil + Stil for undertekst + Bakgrunnsfarge + Nullstill + Fet skrift + Avspillingsmotor + MPV: Bruk maskinvaredekoding + Deaktiver dersom appen krasjer + MPV-valg + ExoPlayer-valg + Hopp over segment + Hopp over reklame + Hopp over forhåndsvisning + Hopp over sammendrag + Hopp over outro + Hopp over intro + Sett + Filter + År + Tiår + Fjern + Dolby Vision + Dolby Atmos + MPV: Bruk gpu-next + Rediger mpv.conf + Forkast endringer? + Margin + Detaljert logging + Tast inn PIN + Logg inn automatisk + Logg inn via server + Trykk i midten for å bekrefte + Krev PIN for profil + Bekreft PIN + Feil + PIN må være 4 taster eller lenger + Vil fjerne PIN + Størrelse på bildebuffer (MB) + Visningsvalg + Kolonner + Mellomrom + Bildeforhold + Vis detaljer + Bildetype + + Gjesteroller + Kantstørrelse + Bytting av bildeoppdateringsfrekvens + Automatisk + Bruk brukernavn/passord + Logg inn + Generelt + Container + Tittel + Kodek + Profil + Nivå + Oppløsning + Anamorfisk + Linjeflettet + Bildefrekvens + Bit-dybde + Videoområde + Type videoområde + Fargerom + Fargeoverføring + Fargeprimærer + Pikselformat + Referanserammer + NAL + Språk + Layout + Kanaler + Samplingsfrekvens + AVC + Ja + Nei + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Vis titler + Repeter + Vis favorittkanaler først + Sorter kanaler etter sist sett + Fargekod programmer + Undertekstforsinkelse + Bakgrunnsstil + Standard + Slette + Død + Regissert av %1$s + Slutter kl. %1$s + Enheten støtter AC3/Dolby Digital + Bytting av oppløsning + Lokal + Spill trailer + Ingen trailere + Fjern valg av spor diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 346cd2c6..3205d05d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -375,4 +375,51 @@ Nenhum servidor encontrado Estilo de fundo Termina às %1$s + Mudança de resolução + Local + Reproduzir trailer + Sem trailers + Apagar seleções de faixas + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Apenas Legendas Forçadas + Reprodução direta Dolby Vision Profile 7 + Ignora as verificações de compatibilidade do dispositivo + Descobrir + Pedir + Pendente + Integração com Seerr + Remover Servidor Seerr + Servidor Seerr adicionado + URL + Popular + Filmes em Breve + Séries em Breve + Pedir em 4K + Palavra-passe + Utilizador + Pesquisa por voz + A Iniciar + Fale para pesquisar + A Processar + Pressione recuar para cancelar + Erro na gravação de áudio + Erro no reconhecimento de voz + É necessária permissão para o microfone + Erro de rede + Tempo limite da rede + Nenhuma fala reconhecida + Reconhecimento de voz ocupado + Erro do servidor + Nenhuma fala detetada + Erro desconhecido + Falha ao iniciar o reconhecimento de voz + Tempo limite do reconhecimento de voz expirado + Tentar novamente + Decodificação de AV1 por software + O MPV é agora o reprodutor padrão, exceto para HDR.\nPode alterar esta configuração nas definições. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index aeb28c50..04204a19 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -299,15 +299,15 @@ Отрывки - - - + + + Дополнительные материалы - - - + + + Выбор плеера Толщина обводки @@ -316,15 +316,15 @@ Форсированные Саундтреки - - - + + + Видеоролики - - - + + + Настроить элементы боковой панели Выберите элементы для показа на панели, остальные элементы будут скрыты @@ -339,8 +339,58 @@ Остановка воспроизведения при бездействии Короткометражки - - - + + + + Конец в %1$s + Введите адрес сервера + Серверы не найдены + Только форсированные субтитры + Голосовой поиск + Запуск + Говорите для поиска + Обработка + Нажмите назад для отмены + Ошибка записи звука + Ошибка распознавания голоса + Требуется разрешение на использование микрофона + Сетевая ошибка + Сетевой таймаут + Речь не распознана + Голосовое распознавание занято + Ошибка сервера + Речь не обнаружена + Неизвестная ошибка + Не удалось запустить распознавание голоса + Время ожидания распознавания голоса истекло + Повторить + О медиаданных + Переключение частоты обновления + Автоматически + Исп. имя пользователя/пароль + Логин + Пароль + Имя пользователя + Язык + Основное + Контейнер + Заголовок + Кодек + Профиль + Уровень + Разрешение + Анаморфность + Чересстрочность + Ч-та кадров + Разрядность + Диапазон видео + Тип диапазона видео + Цветовое пространство + Передача цвета + Основные цвета + Формат пикселей + Опорные кадры + Да + Нет diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml new file mode 100644 index 00000000..dce8332a --- /dev/null +++ b/app/src/main/res/values-uk/strings.xml @@ -0,0 +1,456 @@ + + + Про застосунок + Активні Записи + Улюблене + Додати сервер + Додати користувача + Аудіо + Місце народження + Бітрейт + Місце народженя + Скасувати запис + Скасувати запис серіалу + Скасувати + Розділи + Виберіть%1$s + Очистити кеш зображень + Колекція + Колекції + Реклама + Оцінка спільноти + Сталася помилка! Натисніть кнопку, щоб надіслати звіт на ваш сервер. + Підтвердити + Продовжити перегляд + Оцінка критиків + %.1fсекунд + За замовчуванням + Видалити + Помер + Режисер: %1$s + Режисер + Вимкнутий + Виявлені сервери + + Завантаження… + Увімкнено + Закінчується о %1$s + Введіть IP-адресу сервера або URL-адресу + Введіть адресу сервера + Епізоди + Помилка під час завантаження колекції %1$s + Зовнішній + Улюблені + Розмір + Forced + Жанри + Перейти до серії + Перейти до + Приховати елементи керування відтворенням + Приховати інформацію про налагодження + Приховати + Головна + Негайно + Вступ + #абвгґдеєжзиіїйклмнопрстуфхцчшщьюя + Бібліотека + Інформація про ліцензію + Ефірне ТБ + Завантаження… + Серія переглянута? + Позначити серію не відтворену? + Позначити як непереглянуте + Позначити як переглянуте + Максимальна швидкість передачі даних + Більше подібного + Більше + + Фільми + + + + + Ім\'я + Наступний + Дані відсутні + Відсутні результати + Сервери не знайдені + Заплановані записи відсутні + Немає доступних оновлень + Нічого + Тільки примусові субтитри + Завершення + Шлях + + Люди + + + + + Кількість відтворень + Відтворити звідси + Відтворити + Показати інформацію про налагодження відтворення + Швидкість відтворення + Відтворення + Список відтворення + Списки відтворення + Попередній перегляд + Налаштування користувача + Черга + Підсумок + Нещодавно додано: %1$s + Нещодавно додано + Нещодавно записано + Нещодавно випущено + Рекомендовано + Програма запису + Запис серії + Не улюблений + Перезавантаження + Відновити + Зберегти + + Пошук + Шукаю… + Голосовий пошук + Початок + Говорити для пошуку + Обробка + Натисніть назад, щоб скасувати + Помилка аудіозапису + Помилка розпізнавання голосу + Потрібен дозвіл на використання мікрофона + Помилка мережі + Час очікування мережі завершився + Не розпізнано мовлення + Розпізнавання голосу зайнято + Помилка сервера + Не виявлено мовлення + Невідома помилка + Не вдалося запустити розпізнавання голосу + Час очікування розпізнавання голосу закінчився + Повторити спробу + Обрати сервер + Обрати користувача + Показати додаткову інформацію + Показати наступне + Показати + Перемішати + Пропустити + Дата додавання + Дата додавання епізоду + Дата відтворення + Дата випуску + Ім\'я + Випадковий + Судії + Надіслати + Завантаження триває надто довго, можливо, потрібно перезапустити відтворення + Підзаголовок + Субтитри + Пропозиції + Перемикнути сервер + Перемкнути + Топ непереглянутих + Трейлер + + Трейлери + + + + + Розклад DVR + Посібник + Сезон + Сезони + + Серіали + + + + + Інтерфейс + Невідомо + Оновлення + Версія + Маштаб відео + Відео + Відоси + Дивитись онлайн + %1$d років + Відтворити з перекодуванням + Інформація + Показати годиник + Порядок ефірних епізодів + Створити новий список відтворення + Додати до списку відтворення + Пауза в один клік + Натисніть центр D-Pad, щоб призупинити/відтворити + Курсив + Шрифт + Обкладенка + Успіх + Вікова оцінка + Час виконання + Додатково + + Інше + + + + + + Позакадром + + + + + + Тематичні пісні + + + + + + Тематичні відео + + + + + + Кліпи + + + + + + Видалені сцени + + + + + + Інтерв\'ю + + + + + + Сцени + + + + + + Зразки + + + + + + Короткометражні фільми + + + + + + Шорти + + + + + + %s завантаження + %s завантаження + %s завантажень + %s завантажень + + + %d година + %d години + %d годин + %d годин + + + %d елемент + %d елемента + %d елементів + %d елементів + + + %d секунда + %d секунди + %d секунд + %d секунд + + Пристрій підтримує AC3/Dolby Digital + Додаткові налаштування + Розширений інтерфейс користувача + Тема застосунку + Автоматична перевірка оновлень + Затримка перед відтворенням наступного + Автоматичне відтворення наступного + Перевірити оноалення + Застосовується тільки до серіалів + Пряме відтворення субтитрів ASS + Пряме відтворення субтитрів PGS + Завжди змішувати в стерео + Використовувати модуль декодера FFmpeg + Масштабувати контент за замовчуванням + Встановити оновлення + Встановлена версія + Максимальна кількість елементів в рядку на головній сторінці + Виберіть елементи, які будуть відображатися за замовчуванням, інші будуть приховані + Налаштування елементів навігаційного меню + Натисніть, щоб перейти на іншу сторінку + Перемикання сторінок навігаційного меню при фокусуванні + Захист від відключення + Відтворити музичну тему + Перегляд записів + Запам\'ятати вибрані вкладки + Увімкнути повторний перегляд як наступне + Кроки пошуку + Корисно для налагодження + Надіслати журнали програми на поточний сервер + Спробує надіслати на останній підключений сервер + Надіслати звіти про збій + Налаштування + Перейти назад при відновленні відтворення + Перейти назад + Пропускати рекламу + Перемотати вперед + Пропустити початок + Пропустити кінець + Пропусти попередній перегляд + Пропустити підсумок + Оновлення доступні + URL для перевірки оновлень + Оновити URL + Розмір шрифта + Колір шрифта + Непрозорість шрифту + Стиль контурів + Колір контуру + Прозорість фону + Стиль фону + Стиль заголовку + Колір фону + Скинути + Жирний шрифт + Playback Backend + MPV: Використовувати апаратне декодування + Вимкніть, якщо ви стикаєтеся з проблемами + Варіанти MPV + Параметри ExoPlayer + Пропустити сегмент + Пропустити реклами + Пропустити попередній перегляд + Пропустити повтор + Пропустити кінець + Пропустити вступ + Відтворено + Фільтр + Рік + Десятиліття + Видалити + Dolby Vision + Dolby Atmos + MPV: Використовувати gpu + Редагувати mpv.conf + Відмінити зміни? + Відступ + Детальне ведення журналу + Ведіть PIN + Автоматичний вхід + Увійти через сервер + Натисніть по центру для підтвердження + Вимагати PIN для профілю + Підтвердити PIN + Невірно + PIN-код повинен складатися з 4 або більше символів + Видалити PIN-код + Розмір кешу зображень (МБ) + Переглянути параметри + Колонки + Відступ(Інтервал) + Співвідношення сторін + Показати деталі + + Тип зображення + + Запрошені зірки + Розмір контуру + Перемикання частоти оновлення + Автоматично + Використовуйте ім\'я користувача/пароль + Увійти + Загальні + Контейнер + Заголовок + Codec + Профіль + Рівень + Розширення + Anamorphic + Interlaced + Частота кадрів + Bit depth + Діапазон відео + Тип діапазону відео + Кольоровий простір + Перенесення кольору + Головний колір + Формат пікселю + Ref frames + NAL + Мова + Компонування + Канали + Sample rate + AVC + Так + Ні + SDR + HDR + HDR10 + HDR10+ + HLG + біт + Гц + Показати заголовок + Повторити + Показати обрані канали на початку + Відсортувати канали за нещодавно переглянутими + Кольоровий код програм + Затримка субтитрів + Стиль фону + Перемикання розширення + Локальний + Відтворити трейлер + Трейлери відсутні + Чіткий вибір доріжки + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Пряме відтворення Dolby Vision Profile 7 + Ігноруваии перевірку сумісності пристроїв + Відкрийте для себе + Запит + В очікувані + Seerr інтеграція + Видалити Seerr Server + Seerr сервер додано + Пароль + Нік + URL + Популярні + Заплановані Фільми + Заплановані Серіали + Запит в 4К + Програмне декодування AV1 + MPV тепер є плеєром за замовчуванням, за винятком для HDR.\nВи можете змінити це в налаштуваннях. + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e8223887..10a25eac 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -254,9 +254,9 @@ 重置 粗体 播放后端 - MPV:使用硬件解码 + mpv:使用硬件解码 如果遇到崩溃,请禁用 - MPV 选项 + mpv 选项 ExoPlayer 选项 跳过片段 跳过广告 @@ -271,7 +271,7 @@ 移除 杜比视界 杜比全景声 - MPV:使用 gpu-next + mpv:使用 gpu-next 编辑 mpv.conf 舍弃更改? 边距 @@ -343,4 +343,51 @@ 未找到服务器 背景幕样式 结束于 %1$s + 分辨率切换 + 本地 + 播放预告片 + 无预告片 + 清除轨道选择 + 仅强制字幕 + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + 直通播放杜比视界配置文件 7 + 忽略设备兼容性检查 + 发现 + 请求 + 待处理 + Seerr 集成 + 移除 Seerr 服务器 + 密码 + 用户名 + URL + 热门趋势 + 即将上映的电影 + 即将播出的电视节目 + 请求 4K 版本 + Seerr 服务器已添加 + 语音搜索 + 开始 + 说话进行搜索 + 正在处理 + 按返回键取消 + 音频录制错误 + 语音识别错误 + 需要麦克风权限 + 网络错误 + 网络超时 + 未识别到语音 + 语音识别繁忙 + 服务器错误 + 未检测到语音 + 未知错误 + 无法启动语音识别 + 语音识别超时 + 重试 + AV1 软件解码 + 除 HDR 视频外,mpv 现在是默认播放器。\n您可以在设置中更改此设置。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index efa7096c..fd54746c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -343,4 +343,50 @@ 取樣率 進度條跳轉值 播放覆蓋設定 + 解析度切換 + 播放預告片 + 本地 + 無預告片 + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + 直接播放杜比視界 Profile 7 + 忽略設備相容性檢查 + 探索 + 請求 + 待處理 + Seerr 整合 + 移除 Seerr 伺服器 + 已新增 Seerr 伺服器 + 密碼 + 使用者名稱 + URL + 當前趨勢 + 即將上映的電影 + 即將播出的電視劇 + 請求 4K 版本 + 僅強制字幕 + 清除音軌/字幕選擇 + 語音搜尋 + 開始 + 請說出搜尋內容 + 處理中 + 按返回鍵取消 + 語音錄製錯誤 + 語音辨識錯誤 + 請開啟麥克風權限 + 網路錯誤 + 網路逾時 + 未辨識到語音 + 語音辨識忙碌中 + 伺服器錯誤 + 未偵測到語音 + 未知錯誤 + 無法啟動語音辨識 + 語音辨識逾時 + 重試 + AV1 軟體解碼 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58fd02b1..c83a3b63 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -461,6 +461,7 @@ Upcoming TV Shows Request in 4K AV1 software decoding + MPV is now the default player except for HDR.\nYou can change this in settings. Disabled @@ -532,8 +533,15 @@ - ExoPlayer (default) - MPV (Experimental) + ExoPlayer + MPV + Prefer MPV + + + + + + Use ExoPlayer for HDR playback diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index e8c9c685..d619d042 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -897,8 +897,6 @@ components: - Bloopers site: type: string - enum: - - 'YouTube' MovieDetails: type: object properties: @@ -1226,6 +1224,10 @@ components: type: array items: $ref: '#/components/schemas/WatchProviders' + relatedVideos: + type: array + items: + $ref: '#/components/schemas/RelatedVideo' MediaRequest: type: object properties: diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt new file mode 100644 index 00000000..76893f8c --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt @@ -0,0 +1,65 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.ui.setup.seerr.createUrls +import org.junit.Assert +import org.junit.Test + +class TestSeerr { + @Test + fun testCreateUrls() { + val urls = + createUrls("jellyseerr.com") + .map { it.toString() } + + val expected = + listOf( + "http://jellyseerr.com/api/v1", + "https://jellyseerr.com/api/v1", + "http://jellyseerr.com:5055/api/v1", + "https://jellyseerr.com:5055/api/v1", + ) + Assert.assertEquals(expected, urls) + } + + @Test + fun testCreateUrls2() { + val urls = + createUrls("https://jellyseerr.com") + .map { it.toString() } + + val expected = + listOf( + "https://jellyseerr.com/api/v1", + "https://jellyseerr.com:5055/api/v1", + ) + Assert.assertEquals(expected, urls) + } + + @Test + fun testCreateUrls3() { + val urls = + createUrls("http://jellyseerr.com") + .map { it.toString() } + + val expected = + listOf( + "http://jellyseerr.com/api/v1", + "http://jellyseerr.com:5055/api/v1", + ) + Assert.assertEquals(expected, urls) + } + + @Test + fun testCreateUrls4() { + val urls = + createUrls("jellyseerr.com:5055") + .map { it.toString() } + + val expected = + listOf( + "http://jellyseerr.com:5055/api/v1", + "https://jellyseerr.com:5055/api/v1", + ) + Assert.assertEquals(expected, urls) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt index e41bf292..f4d04c7e 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt @@ -355,7 +355,7 @@ class TestStreamChoiceServiceSmart( userAudioLang = null, ), TestInput( - 1, + null, SubtitlePlaybackMode.SMART, subtitles = listOf( @@ -367,6 +367,18 @@ class TestStreamChoiceServiceSmart( userSubtitleLang = "eng", userAudioLang = null, ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng"), + subtitle(1, "spa"), + ), + streamAudioLang = "spa", + userSubtitleLang = "spa", + userAudioLang = null, + ), ) } } @@ -498,7 +510,7 @@ class TestStreamChoiceServiceMultipleChoices( ), ), TestInput( - 2, + 0, SubtitlePlaybackMode.SMART, subtitles = listOf( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6ed6b153..161791d5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,10 +13,10 @@ kotlinxCoroutinesCore = "1.10.2" ksp = "2.3.0" coreKtx = "1.17.0" appcompat = "1.7.1" -composeBom = "2025.12.01" +composeBom = "2026.01.01" mockk = "1.14.7" -robolectric = "4.14.1" -multiplatformMarkdownRenderer = "0.39.0" +robolectric = "4.16.1" +multiplatformMarkdownRenderer = "0.39.1" okhttpBom = "5.3.2" programguide = "1.6.0" slf4j2Timber = "1.2" @@ -24,8 +24,8 @@ timber = "5.0.1" tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.2" -androidx-media3 = "1.9.0" +activityCompose = "1.12.3" +androidx-media3 = "1.9.1" coil = "3.3.0" jellyfin-sdk = "1.7.1" nav3Core = "1.0.0" @@ -33,15 +33,15 @@ lifecycleViewmodelNav3 = "2.10.0" material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" datastore = "1.2.0" -kotlinx-serialization = "1.9.0" -protobuf-javalite = "4.33.2" -hilt = "2.57.2" +kotlinx-serialization = "1.10.0" +protobuf-javalite = "4.33.4" +hilt = "2.58" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" -workRuntimeKtx = "2.11.0" +workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" -openapi-generator = "7.18.0" +openapi-generator = "7.19.0" assMedia = "0.4.0-beta01" [libraries]