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
+ Kø
+ 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]