Merge branch 'main' into fea/libass-android-support

This commit is contained in:
Damontecres 2026-02-03 15:28:45 -05:00
commit 0b91d5add7
No known key found for this signature in database
73 changed files with 3969 additions and 1458 deletions

View file

@ -1,12 +1,17 @@
<!-- By submitting this pull request, you acknowledge that you have read the [contributing guide](https://github.com/damontecres/Wholphin/blob/main/CONTRIBUTING.md, including the AI/LLM policy, and [developer's guide](https://github.com/damontecres/Wholphin/blob/main/DEVELOPMENT.md) -->
## Description ## Description
<!-- Describe the changes in detail --> <!-- Describe the changes in detail -->
### Related issues ### Related issues
<!-- If this is a new feature or a change, there must be a discussion in an issue first, then reference the issue here --> <!-- If this is a new feature or a change, there must be a discussion in an issue first, reference the issue here -->
<!-- If fixing a bug, reference the bug issue here, or describe the bug, including steps to reproduce --> <!-- If fixing a bug, reference the bug issue here, or describe the bug, including steps to reproduce -->
### Screenshots ### Testing
<!-- Describe how this change was tested and on what device(s) -->
## Screenshots
<!-- Please include screenshots if the PR alters any UI elements --> <!-- Please include screenshots if the PR alters any UI elements -->
### AI/LLM usage ## AI or LLM usage
<!-- If you used any AI/LLM assistance, please describe where in the code and how you tested it --> <!-- If you used any AI or LLM assistance, please list where in the code and how you tested it -->

View file

@ -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. 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. 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.

View file

@ -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. 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.
<p align="center"> <p align="center">
<a href="https://github.com/damontecres/Wholphin/releases"> <a href="https://github.com/damontecres/Wholphin/releases">
@ -31,29 +31,31 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
### User interface ### User interface
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app - 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 - Option to combine Continue Watching & Next Up rows
- Show Movie/TV Show titles when browsing libraries - Show Movie/TV Show titles when browsing libraries
- Play theme music, if available - 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)) - Search & download subtitles (requires compatible server plugin such as [OpenSubtitles](https://github.com/jellyfin/jellyfin-plugin-opensubtitles))
- Customize layout grids for libraries - Customize layout grids for libraries
- Multiple app color themes - Multiple app color themes
- Protect user profile switches with PIN code
### Playback ### Playback
- Different media playback engines, including: - Different media playback engines:
- Default ExoPlayer/Media3 - **ExoPlayer** w/ optional extra audio & AV1 software decoding
- Experimental MPV - **MPV** for direct playing anything plus ASS subtitle support
- Plex inspired playback controls, such as: - Plex inspired playback controls:
- Using D-Pad left/right for seeking during playback - Using D-Pad left/right for seeking during playback
- Quickly access video chapters & queue during playback - Quickly access video chapters & queue during playback
- Optionally skip back a few seconds when resuming playback - Optionally skip back a few seconds when resuming playback
- Live TV & DVR support - Live TV & DVR support
- Auto play next episodes with pass out protection - 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 - Trickplay support
- Other (subjective) enhancements: - Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
- 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
### Roadmap ### Roadmap
@ -61,6 +63,8 @@ See [here for the roadmap](https://github.com/damontecres/Wholphin/wiki#roadmap)
## Installation ## 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` Downloader Code: `8668671`
1. Enable side-loading "unknown" apps 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://www.xda-developers.com/how-to-sideload-apps-android-tv/
- https://developer.android.com/distribute/marketing-tools/alternative-distribution#unknown-sources - 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/ - 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 - 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 - 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! - 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. 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 ## 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! 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 ## Contributions
Issues and pull requests are always welcome! Please check before submitting that your issue or pull request is not a duplicate. Issues and pull requests are always welcome! Please check before submitting that your issue or pull request is not a duplicate.

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@ -138,8 +139,17 @@ class MainActivity : AppCompatActivity() {
window.attributes = attrs.apply { preferredDisplayModeId = modeId } 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() viewModel.appStart()
val requestedDestination = this.intent?.let(::extractDestination)
setContent { setContent {
val appPreferences by userPreferencesDataStore.data.collectAsState(null) val appPreferences by userPreferencesDataStore.data.collectAsState(null)
appPreferences?.let { appPreferences -> appPreferences?.let { appPreferences ->
@ -246,6 +256,10 @@ class MainActivity : AppCompatActivity() {
} }
if (showContent) { if (showContent) {
val requestedDestination =
remember(intent) {
intent?.let(::extractDestination)
}
ApplicationContent( ApplicationContent(
user = current.user, user = current.user,
server = current.server, server = current.server,
@ -292,14 +306,6 @@ class MainActivity : AppCompatActivity() {
super.onRestart() super.onRestart()
Timber.d("onRestart") Timber.d("onRestart")
viewModel.appStart() 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() { override fun onStop() {
@ -341,6 +347,7 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
Timber.v("onNewIntent") Timber.v("onNewIntent")
setIntent(intent)
extractDestination(intent)?.let { extractDestination(intent)?.let {
navigationManager.replace(it) navigationManager.replace(it)
} }
@ -392,7 +399,7 @@ class MainActivityViewModel
@Inject @Inject
constructor( constructor(
private val preferences: DataStore<AppPreferences>, private val preferences: DataStore<AppPreferences>,
private val serverRepository: ServerRepository, val serverRepository: ServerRepository,
private val navigationManager: SetupNavigationManager, private val navigationManager: SetupNavigationManager,
private val deviceProfileService: DeviceProfileService, private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
@ -402,15 +409,20 @@ class MainActivityViewModel
try { try {
val prefs = val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
if (prefs.signInAutomatically) { val userHasPin = serverRepository.currentUser.value?.hasPin == true
if (prefs.signInAutomatically && !userHasPin) {
val current = val current =
serverRepository.restoreSession( serverRepository.restoreSession(
prefs.currentServerId?.toUUIDOrNull(), prefs.currentServerId?.toUUIDOrNull(),
prefs.currentUserId?.toUUIDOrNull(), prefs.currentUserId?.toUUIDOrNull(),
) )
if (current != null) { if (current != null) {
if (current.user.hasPin) {
navigationManager.navigateTo(SetupDestination.UserList(current.server))
} else {
// Restored // Restored
navigationManager.navigateTo(SetupDestination.AppContent(current)) navigationManager.navigateTo(SetupDestination.AppContent(current))
}
} else { } else {
// Did not restore // Did not restore
navigationManager.navigateTo(SetupDestination.ServerList) navigationManager.navigateTo(SetupDestination.ServerList)

View file

@ -10,6 +10,7 @@ import androidx.compose.runtime.ExperimentalComposeRuntimeApi
import androidx.hilt.work.HiltWorkerFactory import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import okhttp3.OkHttp
import org.acra.ACRA import org.acra.ACRA
import org.acra.ReportField import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
@ -63,6 +64,7 @@ class WholphinApplication :
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
OkHttp.initialize(this)
initAcra { initAcra {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON reportFormat = StringFormat.JSON

View file

@ -142,8 +142,6 @@ class ServerRepository
} else { } else {
val user = serverAndUsers.users.firstOrNull { it.id == userId } val user = serverAndUsers.users.firstOrNull { it.id == userId }
if (user != null) { if (user != null) {
// TODO pin-related
// if (user != null && !user.hasPin) {
return changeUser(serverAndUsers.server, user) return changeUser(serverAndUsers.server, user)
} }
} }

View file

@ -39,6 +39,17 @@ val DefaultForGenresFilterOptions =
DecadeFilter, DecadeFilter,
) )
val DefaultPlaylistItemsOptions =
listOf(
PlayedFilter,
FavoriteFilter,
CommunityRatingFilter,
OfficialRatingFilter,
VideoTypeFilter,
YearFilter,
DecadeFilter,
)
sealed interface ItemFilterBy<T> { sealed interface ItemFilterBy<T> {
@get:StringRes @get:StringRes
val stringRes: Int val stringRes: Int

View file

@ -6,6 +6,7 @@ import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.ui.DateFormatter 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.CardGridItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.dot
@ -71,8 +72,7 @@ data class BaseItem(
@Transient @Transient
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
@Transient val indexNumber get() = data.indexNumber
val indexNumber = data.indexNumber ?: dateAsIndex()
val playbackPosition get() = data.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val playbackPosition get() = data.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
@ -88,6 +88,15 @@ data class BaseItem(
@Transient @Transient
val ui = val ui =
BaseItemUi( 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 = quickDetails =
buildAnnotatedString { buildAnnotatedString {
val details = val details =
@ -191,5 +200,7 @@ val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let {
@Immutable @Immutable
data class BaseItemUi( data class BaseItemUi(
val episodeCornerText: String?,
val episdodeUnplayedCornerText: String?,
val quickDetails: AnnotatedString, val quickDetails: AnnotatedString,
) )

View file

@ -752,10 +752,23 @@ sealed interface AppPreference<Pref, T> {
prefs.updatePlaybackPreferences { playerBackend = value } prefs.updatePlaybackPreferences { playerBackend = value }
}, },
displayValues = R.array.player_backend_options, displayValues = R.array.player_backend_options,
subtitles = R.array.player_backend_options_subtitles,
indexToValue = { PlayerBackend.forNumber(it) }, indexToValue = { PlayerBackend.forNumber(it) },
valueToIndex = { it.number }, valueToIndex = { it.number },
) )
val ExoPlayerSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.exoplayer_options,
destination = Destination.Settings(PreferenceScreenOption.EXO_PLAYER),
)
val MpvSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.mpv_options,
destination = Destination.Settings(PreferenceScreenOption.MPV),
)
val MpvHardwareDecoding = val MpvHardwareDecoding =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.mpv_hardware_decoding, title = R.string.mpv_hardware_decoding,
@ -770,7 +783,7 @@ sealed interface AppPreference<Pref, T> {
val MpvGpuNext = val MpvGpuNext =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.mpv_use_gpu_next, title = R.string.mpv_use_gpu_next,
defaultValue = true, defaultValue = false,
getter = { it.playbackPreferences.mpvOptions.useGpuNext }, getter = { it.playbackPreferences.mpvOptions.useGpuNext },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateMpvOptions { useGpuNext = value } prefs.updateMpvOptions { useGpuNext = value }
@ -932,8 +945,7 @@ val basicPreferences =
title = R.string.profile_specific_settings, title = R.string.profile_specific_settings,
preferences = preferences =
listOf( listOf(
// TODO PIN-related AppPreference.RequireProfilePin,
// AppPreference.RequireProfilePin,
AppPreference.UserPinnedNavDrawerItems, AppPreference.UserPinnedNavDrawerItems,
), ),
), ),
@ -959,6 +971,40 @@ val basicPreferences =
val uiPreferences = listOf<PreferenceGroup>() val uiPreferences = listOf<PreferenceGroup>()
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 = val advancedPreferences =
buildList { buildList {
add( add(
@ -1009,22 +1055,17 @@ val advancedPreferences =
listOf( listOf(
ConditionalPreferences( ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.EXO_PLAYER }, { it.playbackPreferences.playerBackend == PlayerBackend.EXO_PLAYER },
listOf( ExoPlayerSettings,
AppPreference.FfmpegPreference,
AppPreference.DownMixStereo,
AppPreference.Ac3Supported,
AppPreference.DirectPlayAss,
AppPreference.DirectPlayPgs,
AppPreference.DirectPlayDoviProfile7,
AppPreference.DecodeAv1,
),
), ),
ConditionalPreferences( ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.MPV }, { it.playbackPreferences.playerBackend == PlayerBackend.MPV },
MpvSettings,
),
ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.PREFER_MPV },
listOf( listOf(
AppPreference.MpvHardwareDecoding, AppPreference.ExoPlayerSettings,
AppPreference.MpvGpuNext, AppPreference.MpvSettings,
AppPreference.MpvConfFile,
), ),
), ),
), ),
@ -1109,6 +1150,7 @@ data class AppChoicePreference<Pref, T>(
override val getter: (prefs: Pref) -> T, override val getter: (prefs: Pref) -> T,
override val setter: (prefs: Pref, value: T) -> Pref, override val setter: (prefs: Pref, value: T) -> Pref,
@param:StringRes val summary: Int? = null, @param:StringRes val summary: Int? = null,
@param:ArrayRes val subtitles: Int? = null,
) : AppPreference<Pref, T> ) : AppPreference<Pref, T>
data class AppMultiChoicePreference<Pref, T>( data class AppMultiChoicePreference<Pref, T>(

View file

@ -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
}
}
}
} }

View file

@ -75,7 +75,11 @@ class NavigationManager
while (backStack.size > 1) { while (backStack.size > 1) {
backStack.removeLastOrNull() backStack.removeLastOrNull()
} }
if (backStack.isEmpty()) {
backStack.add(0, destination)
} else {
backStack[0] = destination backStack[0] = destination
}
log() log()
} }

View file

@ -23,6 +23,7 @@ import androidx.media3.extractor.DefaultExtractorsFactory
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus 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.preferences.PlayerBackend
import com.github.damontecres.wholphin.util.mpv.MpvPlayer import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import dagger.hilt.android.qualifiers.ApplicationContext 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.kt.withAssMkvSupport
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
import io.github.peerless2012.ass.media.type.AssRenderType import io.github.peerless2012.ass.media.type.AssRenderType
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.lang.reflect.Constructor import java.lang.reflect.Constructor
import javax.inject.Inject import javax.inject.Inject
@ -52,27 +53,25 @@ class PlayerFactory
var currentPlayer: Player? = null var currentPlayer: Player? = null
private set private set
fun createVideoPlayer(): PlayerCreation { suspend fun createVideoPlayer(
backend: PlayerBackend,
prefs: PlaybackPreferences,
): PlayerCreation {
withContext(Dispatchers.Main) {
if (currentPlayer?.isReleased == false) { if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!") Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release() currentPlayer?.release()
} }
}
var assHandler: AssHandler? = null var assHandler: AssHandler? = null
val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences }
val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue
val newPlayer = val newPlayer =
when (backend) { when (backend) {
PlayerBackend.MPV -> { PlayerBackend.PREFER_MPV,
val enableHardwareDecoding = PlayerBackend.MPV,
prefs?.mpvOptions?.enableHardwareDecoding -> {
?: AppPreference.MpvHardwareDecoding.defaultValue val enableHardwareDecoding = prefs.mpvOptions.enableHardwareDecoding
val useGpuNext = val useGpuNext = prefs.mpvOptions.useGpuNext
prefs?.mpvOptions?.useGpuNext
?: AppPreference.MpvGpuNext.defaultValue
MpvPlayer(context, enableHardwareDecoding, useGpuNext) MpvPlayer(context, enableHardwareDecoding, useGpuNext)
.apply {
playWhenReady = true
}
} }
PlayerBackend.EXO_PLAYER, PlayerBackend.EXO_PLAYER,
@ -122,7 +121,6 @@ class PlayerFactory
.build() .build()
.apply { .apply {
assHandler?.init(this) assHandler?.init(this)
playWhenReady = true
} }
} }
} }

View file

@ -17,7 +17,6 @@ import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
import com.github.damontecres.wholphin.util.TransformList import com.github.damontecres.wholphin.util.TransformList
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope 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.SortOrder
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -79,19 +77,22 @@ class PlaylistCreator
suspend fun createFromPlaylistId( suspend fun createFromPlaylistId(
playlistId: UUID, playlistId: UUID,
startIndex: Int?, startIndex: Int?,
shuffled: Boolean, sortAndDirection: SortAndDirection,
filter: GetItemsFilter,
): Playlist { ): Playlist {
val request = val request =
GetPlaylistItemsRequest( filter.applyTo(
playlistId = playlistId, GetItemsRequest(
userId = serverRepository.currentUser.value?.id,
parentId = playlistId,
fields = DefaultItemFields, fields = DefaultItemFields,
startIndex = startIndex, startIndex = startIndex,
limit = Playlist.MAX_SIZE, limit = Playlist.MAX_SIZE,
sortBy = listOf(sortAndDirection.sort),
sortOrder = listOf(sortAndDirection.direction),
),
) )
var items = GetPlaylistItemsRequestHandler.execute(api, request).content.items val items = GetItemsRequestHandler.execute(api, request).content.items
if (shuffled) {
items = items.shuffled()
}
return Playlist(items.convertAndAddParts(), 0) return Playlist(items.convertAndAddParts(), 0)
} }
@ -206,9 +207,18 @@ class PlaylistCreator
BaseItemKind.PLAYLIST -> { BaseItemKind.PLAYLIST -> {
PlaylistCreationResult.Success( PlaylistCreationResult.Success(
createFromPlaylistId( createFromPlaylistId(
item.id, playlistId = item.id,
startIndex, startIndex = startIndex,
shuffled, sortAndDirection =
if (shuffled) {
SortAndDirection(ItemSortBy.RANDOM, SortOrder.ASCENDING)
} else {
sortAndDirection ?: SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
)
},
filter = filter,
), ),
) )
} }

View file

@ -30,6 +30,7 @@ import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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. * 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?, username: String?,
passwordOrApiKey: String, passwordOrApiKey: String,
): LoadingState { ): 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) login(api, authMethod, username, passwordOrApiKey)
return LoadingState.Success return LoadingState.Success
} }
@ -185,6 +196,7 @@ private suspend fun login(
password = password ?: "", password = password ?: "",
), ),
) )
client.usersApi.authMeGet()
} }
SeerrAuthMethod.JELLYFIN -> { SeerrAuthMethod.JELLYFIN -> {
@ -194,6 +206,7 @@ private suspend fun login(
password = password ?: "", password = password ?: "",
), ),
) )
client.usersApi.authMeGet()
} }
SeerrAuthMethod.API_KEY -> { SeerrAuthMethod.API_KEY -> {

View file

@ -276,10 +276,16 @@ class StreamChoiceService
SubtitlePlaybackMode.SMART -> { SubtitlePlaybackMode.SMART -> {
if (subtitleLanguage.isNotNullOrBlank()) { if (subtitleLanguage.isNotNullOrBlank()) {
val audioLanguage = userConfig?.audioLanguagePreference 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 == subtitleLanguage }
?: candidates.firstOrNull { it.language.isUnknown } ?: candidates.firstOrNull { it.language.isUnknown }
} else { } else {
// Otherwise, show forced subtitles in preferred lang
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
?: candidates.firstOrNull { it.isForced && it.language.isUnknown } ?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
} }

View file

@ -56,7 +56,6 @@ import kotlin.contracts.contract
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.math.min import kotlin.math.min
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -234,7 +233,7 @@ val Duration.roundMinutes: Duration
* Rounds a [Duration] to nearest whole second * Rounds a [Duration] to nearest whole second
*/ */
val Duration.roundSeconds: Duration 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] * Gets the user's playback position as a [Duration]

View file

@ -27,6 +27,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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.compose.ui.unit.sp
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults 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.Cards
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
/** /**
@ -63,17 +63,19 @@ fun BannerCard(
) { ) {
val imageUrlService = LocalImageUrlService.current val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current val density = LocalDensity.current
val imageUrl =
remember(item, cardHeight) {
if (item != null) {
val fillHeight = val fillHeight =
if (cardHeight != Dp.Unspecified) { remember(cardHeight) {
if (cardHeight.isSpecified) {
with(density) { with(density) {
cardHeight.roundToPx() cardHeight.roundToPx()
} }
} else { } else {
null null
} }
}
val imageUrl =
remember(item, fillHeight) {
if (item != null) {
imageUrlService.getItemImageUrl( imageUrlService.getItemImageUrl(
item, item,
ImageType.PRIMARY, ImageType.PRIMARY,
@ -101,7 +103,7 @@ fun BannerCard(
.fillMaxSize(), .fillMaxSize(),
// .background(MaterialTheme.colorScheme.surfaceVariant), // .background(MaterialTheme.colorScheme.surfaceVariant),
) { ) {
if (!imageError && imageUrl.isNotNullOrBlank()) { if (!imageError && imageUrl != null) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = null, contentDescription = null,
@ -121,7 +123,7 @@ fun BannerCard(
.align(Alignment.Center), .align(Alignment.Center),
) )
} }
if (played || cornerText.isNotNullOrBlank()) { if (played || cornerText != null) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -133,7 +135,7 @@ fun BannerCard(
if (played && (playPercent <= 0 || playPercent >= 100)) { if (played && (playPercent <= 0 || playPercent >= 100)) {
WatchedIcon(Modifier.size(24.dp)) WatchedIcon(Modifier.size(24.dp))
} }
if (cornerText.isNotNullOrBlank()) { if (cornerText != null) {
Box( Box(
modifier = modifier =
Modifier Modifier

View file

@ -47,18 +47,9 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository 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.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.FavoriteFilter
import com.github.damontecres.wholphin.data.filter.FilterValueOption 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.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.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
@ -88,6 +79,7 @@ import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -103,9 +95,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient 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.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType 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.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.util.TreeSet
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@ -391,79 +379,12 @@ class CollectionFolderViewModel
} }
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
try { FilterUtils.getFilterOptionValues(
when (filterOption) { api,
GenreFilter -> { serverRepository.currentUser.value?.id,
api.genresApi itemUuid,
.getGenres( filterOption,
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<Int>()
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()
}
suspend fun positionOfLetter(letter: Char): Int? = suspend fun positionOfLetter(letter: Char): Int? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {

View file

@ -32,6 +32,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@ -183,7 +184,7 @@ fun DialogPopup(
dismissOnClick: Boolean = true, dismissOnClick: Boolean = true,
waitToLoad: Boolean = true, waitToLoad: Boolean = true,
properties: DialogProperties = DialogProperties(), properties: DialogProperties = DialogProperties(),
elevation: Dp = 3.dp, elevation: Dp = 8.dp,
) { ) {
var waiting by remember { mutableStateOf(waitToLoad) } var waiting by remember { mutableStateOf(waitToLoad) }
if (showDialog) { if (showDialog) {
@ -238,7 +239,7 @@ fun DialogPopupContent(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
dismissOnClick: Boolean = true, dismissOnClick: Boolean = true,
elevation: Dp = 3.dp, elevation: Dp = 8.dp,
) { ) {
val elevatedContainerColor = val elevatedContainerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
@ -246,6 +247,7 @@ fun DialogPopupContent(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier =
modifier modifier
.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp))
.graphicsLayer { .graphicsLayer {
this.clip = true this.clip = true
this.shape = RoundedCornerShape(28.0.dp) this.shape = RoundedCornerShape(28.0.dp)
@ -296,7 +298,7 @@ fun DialogPopup(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
dismissOnClick: Boolean = true, dismissOnClick: Boolean = true,
properties: DialogProperties = DialogProperties(), properties: DialogProperties = DialogProperties(),
elevation: Dp = 3.dp, elevation: Dp = 8.dp,
) = DialogPopup( ) = DialogPopup(
showDialog = true, showDialog = true,
waitToLoad = params.fromLongClick, waitToLoad = params.fromLongClick,
@ -345,6 +347,7 @@ fun ScrollableDialog(
.width(width) .width(width)
.heightIn(max = maxHeight) .heightIn(max = maxHeight)
.focusable() .focusable()
.shadow(elevation = 8.dp, shape = RoundedCornerShape(8.dp))
.background( .background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
@ -373,7 +376,7 @@ fun ScrollableDialog(
fun BasicDialog( fun BasicDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
properties: DialogProperties = DialogProperties(), properties: DialogProperties = DialogProperties(),
elevation: Dp = 3.dp, elevation: Dp = 8.dp,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Dialog( Dialog(
@ -383,6 +386,7 @@ fun BasicDialog(
Box( Box(
modifier = modifier =
Modifier Modifier
.shadow(elevation = elevation, shape = RoundedCornerShape(8.dp))
.background( .background(
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation), MaterialTheme.colorScheme.surfaceColorAtElevation(elevation),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
@ -403,7 +407,7 @@ fun ConfirmDialog(
onCancel: () -> Unit, onCancel: () -> Unit,
onConfirm: () -> Unit, onConfirm: () -> Unit,
properties: DialogProperties = DialogProperties(), properties: DialogProperties = DialogProperties(),
elevation: Dp = 3.dp, elevation: Dp = 8.dp,
) = BasicDialog( ) = BasicDialog(
onDismissRequest = onCancel, onDismissRequest = onCancel,
properties = properties, properties = properties,

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement 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.ArrowForward
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -49,7 +52,14 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R 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.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.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import com.github.damontecres.wholphin.ui.components.FilterByButton
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.SortByButton
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.enableMarquee 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.ifElse
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.roundMinutes 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.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.FilterUtils
import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.ui.util.LocalClock
import com.github.damontecres.wholphin.util.ApiRequestPager 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.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient 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 org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
@ -92,9 +115,22 @@ class PlaylistViewModel
api: ApiClient, api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val items = MutableLiveData<List<BaseItem?>>(listOf()) val items = MutableLiveData<List<BaseItem?>>(listOf())
val filterAndSort =
MutableStateFlow<FilterAndSort>(
FilterAndSort(
filter = GetItemsFilter(),
sortAndDirection =
SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
),
),
)
fun init(playlistId: UUID) { fun init(playlistId: UUID) {
loading.value = LoadingState.Loading loading.value = LoadingState.Loading
@ -103,18 +139,92 @@ class PlaylistViewModel
LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"), LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"),
) { ) {
val playlist = fetchItem(playlistId) val playlist = fetchItem(playlistId)
val request = val libraryDisplayInfo =
GetPlaylistItemsRequest( serverRepository.currentUser.value?.let { user ->
playlistId = playlist.id, libraryDisplayInfoDao.getItem(user, itemId)
fields = DefaultItemFields, }
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
val sortAndDirection =
libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
) )
val pager = ApiRequestPager(api, request, GetPlaylistItemsRequestHandler, viewModelScope).init() 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) { withContext(Dispatchers.Main) {
items.value = pager items.value = pager
loading.value = LoadingState.Success 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<FilterValueOption> =
FilterUtils.getFilterOptionValues(
api,
serverRepository.currentUser.value?.id,
itemUuid,
filterOption,
)
fun updateBackdrop(item: BaseItem) { fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO { viewModelScope.launchIO {
@ -123,6 +233,12 @@ class PlaylistViewModel
} }
} }
@Immutable
data class FilterAndSort(
val filter: GetItemsFilter,
val sortAndDirection: SortAndDirection,
)
@Composable @Composable
fun PlaylistDetails( fun PlaylistDetails(
destination: Destination.MediaItem, destination: Destination.MediaItem,
@ -136,42 +252,37 @@ fun PlaylistDetails(
val loading by viewModel.loading.observeAsState(LoadingState.Pending) val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val playlist by viewModel.item.observeAsState(null) val playlist by viewModel.item.observeAsState(null)
val items by viewModel.items.observeAsState(listOf()) val items by viewModel.items.observeAsState(listOf())
val filterAndSort by viewModel.filterAndSort.collectAsState()
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) } var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
when (val st = loading) { val goToString = stringResource(R.string.go_to)
is LoadingState.Error -> { val playFromHereString = stringResource(R.string.play_from_here)
ErrorMessage(st, modifier)
}
LoadingState.Pending, LoadingState.Loading -> {
LoadingPage(modifier)
}
LoadingState.Success -> {
playlist?.let {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
PlaylistDetailsContent( PlaylistDetailsContent(
playlist = it, loadingState = loading,
playlist = playlist,
items = items, items = items,
focusRequester = focusRequester,
onChangeBackdrop = viewModel::updateBackdrop, onChangeBackdrop = viewModel::updateBackdrop,
onClickIndex = { index, _ -> onClickIndex = { index, _ ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.PlaybackList( Destination.PlaybackList(
itemId = it.id, itemId = destination.itemId,
startIndex = index, startIndex = index,
shuffle = false, shuffle = false,
filter = filterAndSort.filter,
sortAndDirection = filterAndSort.sortAndDirection,
), ),
) )
}, },
onClickPlay = { shuffle -> onClickPlay = { shuffle ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.PlaybackList( Destination.PlaybackList(
itemId = it.id, itemId = destination.itemId,
startIndex = 0, startIndex = 0,
shuffle = shuffle, shuffle = shuffle,
filter = filterAndSort.filter,
sortAndDirection = filterAndSort.sortAndDirection,
), ),
) )
}, },
@ -183,31 +294,33 @@ fun PlaylistDetails(
items = items =
listOf( listOf(
DialogItem( DialogItem(
context.getString(R.string.go_to), goToString,
Icons.Default.ArrowForward, Icons.Default.ArrowForward,
) { ) {
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
DialogItem( DialogItem(
context.getString(R.string.play_from_here), playFromHereString,
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
) { ) {
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.PlaybackList( Destination.PlaybackList(
itemId = it.id, itemId = destination.itemId,
startIndex = index, startIndex = index,
shuffle = false, shuffle = false,
filter = filterAndSort.filter,
sortAndDirection = filterAndSort.sortAndDirection,
), ),
) )
}, },
), ),
) )
}, },
filterAndSort = filterAndSort,
onFilterAndSortChange = viewModel::loadItems,
getPossibleFilterValues = viewModel::getFilterOptionValues,
modifier = modifier, modifier = modifier,
) )
}
}
}
longClickDialog?.let { params -> longClickDialog?.let { params ->
DialogPopup( DialogPopup(
params = params, params = params,
@ -218,14 +331,17 @@ fun PlaylistDetails(
@Composable @Composable
fun PlaylistDetailsContent( fun PlaylistDetailsContent(
playlist: BaseItem, playlist: BaseItem?,
items: List<BaseItem?>, items: List<BaseItem?>,
onClickIndex: (Int, BaseItem) -> Unit, onClickIndex: (Int, BaseItem) -> Unit,
onLongClickIndex: (Int, BaseItem) -> Unit, onLongClickIndex: (Int, BaseItem) -> Unit,
onClickPlay: (shuffle: Boolean) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit, onChangeBackdrop: (BaseItem) -> Unit,
filterAndSort: FilterAndSort,
onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit,
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
loadingState: LoadingState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) { ) {
var savedIndex by rememberSaveable { mutableIntStateOf(0) } var savedIndex by rememberSaveable { mutableIntStateOf(0) }
var focusedIndex by remember { mutableIntStateOf(savedIndex) } var focusedIndex by remember { mutableIntStateOf(savedIndex) }
@ -234,6 +350,12 @@ fun PlaylistDetailsContent(
LaunchedEffect(focusedItem) { LaunchedEffect(focusedItem) {
focusedItem?.let(onChangeBackdrop) focusedItem?.let(onChangeBackdrop)
} }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(loadingState) {
if (loadingState is LoadingState.Success || loadingState is LoadingState.Error) {
focusRequester.tryRequestFocus()
}
}
val playButtonFocusRequester = remember { FocusRequester() } val playButtonFocusRequester = remember { FocusRequester() }
@ -248,22 +370,35 @@ fun PlaylistDetailsContent(
.fillMaxSize(), .fillMaxSize(),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(24.dp),
horizontalArrangement = Arrangement.spacedBy(32.dp),
modifier = modifier =
Modifier Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 8.dp)
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
PlaylistDetailsHeader( PlaylistDetailsHeader(
focusedItem = focusedItem, focusedItem = focusedItem,
onClickPlay = onClickPlay, onClickPlay = onClickPlay,
playButtonFocusRequester = playButtonFocusRequester, playButtonFocusRequester = playButtonFocusRequester,
focusRequester = if (items.isEmpty()) focusRequester else remember { FocusRequester() },
filterAndSort = filterAndSort,
onFilterAndSortChange = onFilterAndSortChange,
getPossibleFilterValues = getPossibleFilterValues,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp, top = 80.dp)
.fillMaxWidth(.25f), .fillMaxWidth(.25f),
) )
when (loadingState) {
is LoadingState.Error -> {
ErrorMessage(loadingState, modifier)
}
LoadingState.Pending, LoadingState.Loading -> {
LoadingPage(modifier)
}
LoadingState.Success -> {
Column( Column(
modifier = modifier =
Modifier Modifier
@ -271,12 +406,13 @@ fun PlaylistDetailsContent(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) { ) {
Text( Text(
text = playlist.name ?: stringResource(R.string.playlist), text = playlist?.name ?: stringResource(R.string.playlist),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displayMedium, style = MaterialTheme.typography.displayMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
if (items.isNotEmpty()) {
LazyColumn( LazyColumn(
contentPadding = PaddingValues(8.dp), contentPadding = PaddingValues(8.dp),
modifier = modifier =
@ -290,7 +426,11 @@ fun PlaylistDetailsContent(
.surfaceColorAtElevation(1.dp) .surfaceColorAtElevation(1.dp)
.copy(alpha = .75f), .copy(alpha = .75f),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
).focusRequester(focusRequester) ).focusProperties {
onExit = {
playButtonFocusRequester.tryRequestFocus()
}
}.focusRequester(focusRequester)
.focusGroup() .focusGroup()
.focusRestorer(focus), .focusRestorer(focus),
) { ) {
@ -327,6 +467,28 @@ fun PlaylistDetailsContent(
) )
} }
} }
} 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?, focusedItem: BaseItem?,
onClickPlay: (shuffle: Boolean) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit,
playButtonFocusRequester: FocusRequester, playButtonFocusRequester: FocusRequester,
focusRequester: FocusRequester,
filterAndSort: FilterAndSort,
onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit,
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -346,13 +512,14 @@ fun PlaylistDetailsHeader(
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.focusRequester(playButtonFocusRequester), modifier = Modifier,
) { ) {
ExpandablePlayButton( ExpandablePlayButton(
title = R.string.play, title = R.string.play,
resume = Duration.ZERO, resume = Duration.ZERO,
icon = Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
onClick = { onClickPlay.invoke(false) }, onClick = { onClickPlay.invoke(false) },
modifier = Modifier.focusRequester(playButtonFocusRequester),
) )
ExpandableFaButton( ExpandableFaButton(
title = R.string.shuffle, title = R.string.shuffle,
@ -360,16 +527,45 @@ fun PlaylistDetailsHeader(
onClick = { onClickPlay.invoke(true) }, 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(
text = focusedItem?.title ?: "", text = focusedItem?.title ?: "",
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineSmall,
) )
Text( Text(
text = focusedItem?.subtitle ?: "", text = focusedItem?.subtitle ?: "",
color = MaterialTheme.colorScheme.onSurface, 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( OverviewText(
overview = focusedItem?.data?.overview ?: "", overview = focusedItem?.data?.overview ?: "",
maxLines = 10, maxLines = 10,

View file

@ -90,7 +90,7 @@ fun DiscoverMovieDetails(
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf()) val trailers by viewModel.trailers.observeAsState(listOf())
val similar by viewModel.similar.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 loading by viewModel.loading.observeAsState(LoadingState.Loading)
val userConfig by viewModel.userConfig.collectAsState(null) val userConfig by viewModel.userConfig.collectAsState(null)
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
@ -365,7 +365,7 @@ fun DiscoverMovieDetailsContent(
item { item {
ItemRow( ItemRow(
title = stringResource(R.string.recommended), title = stringResource(R.string.recommended),
items = similar, items = recommended,
onClickItem = { index, item -> onClickItem = { index, item ->
position = RECOMMENDED_ROW position = RECOMMENDED_ROW
onClickItem.invoke(index, item) onClickItem.invoke(index, item)

View file

@ -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.GenreText
import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.listToDotString
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
@ -103,7 +104,8 @@ fun DiscoverMovieDetailsHeader(
GenreText(it, Modifier.padding(bottom = padding)) GenreText(it, Modifier.padding(bottom = padding))
} }
movie.tagline?.let { tagline -> val tagline = remember { movie.tagline?.takeIf { it.isNotNullOrBlank() } }
tagline?.let {
Text( Text(
text = tagline, text = tagline,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@ -134,8 +136,9 @@ fun DiscoverMovieDetailsHeader(
remember(movie.credits?.crew) { remember(movie.credits?.crew) {
movie.credits movie.credits
?.crew ?.crew
?.filter { it.job == "Directing" } ?.filter { it.job == "Director" && it.name.isNotNullOrBlank() }
?.joinToString(", ") { it.name!! } ?.joinToString(", ") { it.name!! }
?.takeIf { it.isNotNullOrBlank() }
} }
directorName directorName

View file

@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class) @HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class)
class DiscoverMovieViewModel class DiscoverMovieViewModel
@ -67,8 +68,8 @@ class DiscoverMovieViewModel
val trailers = MutableLiveData<List<Trailer>>(listOf()) val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf()) val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>(listOf()) val similar = MutableLiveData<List<DiscoverItem>>()
val recommended = MutableLiveData<List<DiscoverItem>>(listOf()) val recommended = MutableLiveData<List<DiscoverItem>>()
val canCancelRequest = MutableStateFlow(false) val canCancelRequest = MutableStateFlow(false)
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
@ -99,6 +100,7 @@ class DiscoverMovieViewModel
"Error fetching movie", "Error fetching movie",
), ),
) { ) {
Timber.v("Init for movie %s", item.id)
val movie = fetchAndSetItem().await() val movie = fetchAndSetItem().await()
val discoveredItem = DiscoverItem(movie) val discoveredItem = DiscoverItem(movie)
backdropService.submit(discoveredItem) backdropService.submit(discoveredItem)
@ -116,7 +118,7 @@ class DiscoverMovieViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.moviesApi seerrService.api.moviesApi
.movieMovieIdSimilarGet(movieId = item.id, page = 2) .movieMovieIdSimilarGet(movieId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
@ -125,11 +127,11 @@ class DiscoverMovieViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.moviesApi seerrService.api.moviesApi
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2) .movieMovieIdRecommendationsGet(movieId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
similar.setValueOnMain(result) recommended.setValueOnMain(result)
} }
} }
val people = val people =
@ -147,7 +149,7 @@ class DiscoverMovieViewModel
?.filter { it.type == RelatedVideo.Type.TRAILER } ?.filter { it.type == RelatedVideo.Type.TRAILER }
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
?.map { ?.map {
RemoteTrailer(it.name!!, it.url!!, null) RemoteTrailer(it.name!!, it.url!!, it.site)
}.orEmpty() }.orEmpty()
this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers) this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers)
} }

View file

@ -89,6 +89,7 @@ fun DiscoverSeriesDetails(
val item by viewModel.tvSeries.observeAsState() val item by viewModel.tvSeries.observeAsState()
val seasons by viewModel.seasons.observeAsState(listOf()) val seasons by viewModel.seasons.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf())
val recommended by viewModel.recommended.observeAsState(listOf()) val recommended by viewModel.recommended.observeAsState(listOf())
val userConfig by viewModel.userConfig.collectAsState(null) val userConfig by viewModel.userConfig.collectAsState(null)
@ -155,7 +156,7 @@ fun DiscoverSeriesDetails(
trailerOnClick = { trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo) TrailerService.onClick(context, it, viewModel::navigateTo)
}, },
trailers = listOf(), trailers = trailers,
requestOnClick = { requestOnClick = {
item.id?.let { id -> item.id?.let { id ->
if (request4kEnabled) { if (request4kEnabled) {
@ -255,7 +256,7 @@ fun DiscoverSeriesDetailsContent(
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
var position by rememberInt() var position by rememberInt()
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } }
val playFocusRequester = remember { FocusRequester() } val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequesters.getOrNull(position)?.tryRequestFocus() focusRequesters.getOrNull(position)?.tryRequestFocus()
@ -398,7 +399,7 @@ fun DiscoverSeriesDetailsContent(
item { item {
ItemRow( ItemRow(
title = stringResource(R.string.recommended), title = stringResource(R.string.recommended),
items = similar, items = recommended,
onClickItem = { index, item -> onClickItem = { index, item ->
position = RECOMMENDED_ROW position = RECOMMENDED_ROW
onClickItem.invoke(index, item) onClickItem.invoke(index, item)

View file

@ -4,17 +4,20 @@ import android.content.Context
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest 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.Season
import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating 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.data.model.Trailer
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService 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.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
@ -36,6 +39,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
@HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class) @HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class)
class DiscoverSeriesViewModel class DiscoverSeriesViewModel
@ -62,8 +66,8 @@ class DiscoverSeriesViewModel
val seasons = MutableLiveData<List<Season>>(listOf()) val seasons = MutableLiveData<List<Season>>(listOf())
val trailers = MutableLiveData<List<Trailer>>(listOf()) val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf()) val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>(listOf()) val similar = MutableLiveData<List<DiscoverItem>>()
val recommended = MutableLiveData<List<DiscoverItem>>(listOf()) val recommended = MutableLiveData<List<DiscoverItem>>()
val canCancelRequest = MutableStateFlow(false) val canCancelRequest = MutableStateFlow(false)
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
@ -94,6 +98,7 @@ class DiscoverSeriesViewModel
"Error fetching movie", "Error fetching movie",
), ),
) { ) {
Timber.v("Init for tv %s", item.id)
val tv = fetchAndSetItem().await() val tv = fetchAndSetItem().await()
val discoveredItem = DiscoverItem(tv) val discoveredItem = DiscoverItem(tv)
backdropService.submit(discoveredItem) backdropService.submit(discoveredItem)
@ -110,8 +115,8 @@ class DiscoverSeriesViewModel
if (!similar.isInitialized) { if (!similar.isInitialized) {
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.moviesApi seerrService.api.tvApi
.movieMovieIdSimilarGet(movieId = item.id, page = 2) .tvTvIdSimilarGet(tvId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
@ -119,12 +124,12 @@ class DiscoverSeriesViewModel
} }
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.moviesApi seerrService.api.tvApi
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2) .tvTvIdRecommendationsGet(tvId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
similar.setValueOnMain(result) recommended.setValueOnMain(result)
} }
} }
val people = val people =
@ -137,6 +142,15 @@ class DiscoverSeriesViewModel
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
this@DiscoverSeriesViewModel.people.setValueOnMain(people) 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) { fun navigateTo(destination: Destination) {

View file

@ -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.LoadingPage
import com.github.damontecres.wholphin.ui.components.SeriesName import com.github.damontecres.wholphin.ui.components.SeriesName
import com.github.damontecres.wholphin.ui.components.TabRow 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.ifElse
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
@ -215,9 +214,6 @@ fun SeriesOverviewContent(
if (interactionSource.collectIsFocusedAsState().value) { if (interactionSource.collectIsFocusedAsState().value) {
onFocusEpisode.invoke(episodeIndex) onFocusEpisode.invoke(episodeIndex)
} }
val cornerText =
episode?.data?.indexNumber?.let { "E$it" }
?: episode?.data?.premiereDate?.let(::formatDateTime)
BannerCard( BannerCard(
name = episode?.name, name = episode?.name,
item = episode, item = episode,
@ -226,7 +222,7 @@ fun SeriesOverviewContent(
?.aspectRatio ?.aspectRatio
?.coerceAtLeast(AspectRatios.FOUR_THREE) ?.coerceAtLeast(AspectRatios.FOUR_THREE)
?: (AspectRatios.WIDE), ?: (AspectRatios.WIDE),
cornerText = cornerText, cornerText = episode?.ui?.episodeCornerText,
played = episode?.data?.userData?.played ?: false, played = episode?.data?.userData?.played ?: false,
playPercent = playPercent =
episode?.data?.userData?.playedPercentage episode?.data?.userData?.playedPercentage

View file

@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.google.common.cache.CacheBuilder
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject 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.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi 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.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
@ -121,6 +123,7 @@ class SeriesViewModel
) + Dispatchers.IO, ) + Dispatchers.IO,
) { ) {
Timber.v("Start") Timber.v("Start")
addCloseable { themeSongPlayer.stop() }
val item = fetchItem(seriesId) val item = fetchItem(seriesId)
backdropService.submit(item) backdropService.submit(item)
@ -292,14 +295,16 @@ class SeriesViewModel
listOf( listOf(
ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_SOURCE_COUNT, ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.MEDIA_STREAMS,
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.CUSTOM_RATING, ItemFields.CUSTOM_RATING,
ItemFields.TRICKPLAY,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.PEOPLE,
), ),
) )
Timber.v(
"loadEpisodesInternal: episodeId=%s, episodeNumber=%s",
episodeId,
episodeNumber,
)
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
pager.init(episodeNumber ?: 0) pager.init(episodeNumber ?: 0)
val initialIndex = val initialIndex =
@ -503,19 +508,34 @@ class SeriesViewModel
} }
private var peopleInEpisodeJob: Job? = null private var peopleInEpisodeJob: Job? = null
private val peopleInEpisodeCache =
CacheBuilder
.newBuilder()
.maximumSize(25)
.build<UUID, Deferred<PeopleInItem>>()
suspend fun lookupPeopleInEpisode(item: BaseItem) { suspend fun lookupPeopleInEpisode(item: BaseItem) {
peopleInEpisodeJob?.cancel() peopleInEpisodeJob?.cancel()
if (peopleInEpisode.value?.itemId != item.id) { if (peopleInEpisode.value?.itemId != item.id) {
peopleInEpisode.setValueOnMain(PeopleInItem()) 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 = peopleInEpisodeJob =
viewModelScope.launch(ExceptionHandler()) { viewModelScope.launch(ExceptionHandler()) {
delay(250) delay(250)
val people = peopleInEpisode.setValueOnMain(result.await())
item.data.people
?.letNotEmpty { it.map { Person.fromDto(it, api) } }
.orEmpty()
peopleInEpisode.setValueOnMain(PeopleInItem(item.id, people))
} }
} }
} }

View file

@ -18,18 +18,17 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.UserPreferences 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable @Composable
fun DiscoverPage( fun DiscoverPage(
@ -46,21 +45,16 @@ fun DiscoverPage(
stringResource(R.string.request), stringResource(R.string.request),
) )
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() }
val tabFocusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } val tabFocusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
val firstTabFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) { LaunchedEffect(selectedTabIndex) {
logTab("discover", selectedTabIndex) logTab("discover", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, NavDrawerItem.Discover.id, selectedTabIndex) preferencesViewModel.saveRememberedTab(preferences, NavDrawerItem.Discover.id, selectedTabIndex)
preferencesViewModel.backdropService.clearBackdrop()
} }
OneTimeLaunchedEffect { preferencesViewModel.backdropService.clearBackdrop() }
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus("page") }
Column( Column(
modifier = modifier, modifier = modifier,
) { ) {
@ -73,8 +67,7 @@ fun DiscoverPage(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
modifier = modifier =
Modifier Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp) .padding(start = 32.dp, top = 16.dp, bottom = 16.dp),
.focusRequester(firstTabFocusRequester),
tabs = tabs, tabs = tabs,
onClick = { selectedTabIndex = it }, onClick = { selectedTabIndex = it },
focusRequesters = tabFocusRequesters, focusRequesters = tabFocusRequesters,
@ -87,8 +80,7 @@ fun DiscoverPage(
preferences = preferences, preferences = preferences,
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize(),
.focusRequester(focusRequester),
) )
} }
@ -98,8 +90,7 @@ fun DiscoverPage(
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize(),
.focusRequester(focusRequester),
) )
} }

View file

@ -197,7 +197,7 @@ fun SeerrDiscoverPage(
listOf(state.trending, state.movies, state.tv, state.upcomingMovies, state.upcomingTv) listOf(state.trending, state.movies, state.tv, state.upcomingMovies, state.upcomingTv)
val ratingMap by viewModel.rating.collectAsState() 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) var position by rememberPosition(0, -1)
val focusedItem = val focusedItem =
remember(position) { remember(position) {
@ -212,6 +212,8 @@ fun SeerrDiscoverPage(
LaunchedEffect(state.trending) { LaunchedEffect(state.trending) {
if (!firstFocused && state.trending.items is DataLoadingState.Success<*>) { if (!firstFocused && state.trending.items is DataLoadingState.Success<*>) {
firstFocused = focusRequesters.getOrNull(0)?.tryRequestFocus("discover") == true firstFocused = focusRequesters.getOrNull(0)?.tryRequestFocus("discover") == true
} else if (firstFocused) {
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
} }
} }

View file

@ -40,6 +40,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -112,6 +113,9 @@ class SeerrRequestsViewModel
state.update { it.copy(requests = DataLoadingState.Success(results)) } 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) }.launchIn(viewModelScope)
} }

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.ui.main package com.github.damontecres.wholphin.ui.main
import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
@ -25,7 +24,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -49,7 +47,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress 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.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
@ -88,28 +86,15 @@ fun HomePage(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
var firstLoad by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init(preferences).join() viewModel.init()
firstLoad = false
} }
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading) val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading)
val watchingRows by viewModel.watchingRows.observeAsState(listOf()) val watchingRows by viewModel.watchingRows.observeAsState(listOf())
val latestRows by viewModel.latestRows.observeAsState(listOf()) val latestRows by viewModel.latestRows.observeAsState(listOf())
LaunchedEffect(loading) {
val state = loading val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows }
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()
}
}
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
@ -127,7 +112,7 @@ fun HomePage(
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
HomePageContent( HomePageContent(
watchingRows + latestRows, homeRows = homeRows,
onClickItem = { position, item -> onClickItem = { position, item ->
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
@ -219,15 +204,15 @@ fun HomePageContent(
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } } val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
var firstFocused by remember { mutableStateOf(false) } var firstFocused by remember { mutableStateOf(false) }
LaunchedEffect(homeRows) { LaunchedEffect(homeRows) {
if (!firstFocused) { if (!firstFocused && homeRows.isNotEmpty()) {
if (position.row >= 0) { if (position.row >= 0) {
rowFocusRequesters[position.row].tryRequestFocus() val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
firstFocused = true firstFocused = true
} else { } else {
// Waiting for the first home row to load, then focus on it // Waiting for the first home row to load, then focus on it
homeRows homeRows
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
.takeIf { it >= 0 }
?.let { ?.let {
rowFocusRequesters[it].tryRequestFocus() rowFocusRequesters[it].tryRequestFocus()
firstFocused = true firstFocused = true
@ -238,8 +223,10 @@ fun HomePageContent(
} }
} }
LaunchedEffect(position) { LaunchedEffect(position) {
if (position.row >= 0) {
listState.animateScrollToItem(position.row) listState.animateScrollToItem(position.row)
} }
}
LaunchedEffect(onUpdateBackdrop, focusedItem) { LaunchedEffect(onUpdateBackdrop, focusedItem) {
focusedItem?.let { onUpdateBackdrop.invoke(it) } focusedItem?.let { onUpdateBackdrop.invoke(it) }
} }
@ -337,21 +324,11 @@ fun HomePageContent(
.focusRequester(rowFocusRequesters[rowIndex]) .focusRequester(rowFocusRequesters[rowIndex])
.animateItem(), .animateItem(),
cardContent = { index, item, cardModifier, onClick, onLongClick -> 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( BannerCard(
name = item?.data?.seriesName ?: item?.name, name = item?.data?.seriesName ?: item?.name,
item = item, item = item,
aspectRatio = AspectRatios.TALL, aspectRatio = AspectRatios.TALL,
cornerText = cornerText, cornerText = item?.ui?.episdodeUnplayedCornerText,
played = item?.data?.userData?.played ?: false, played = item?.data?.userData?.played ?: false,
favorite = item?.favorite ?: false, favorite = item?.favorite ?: false,
playPercent = playPercent =
@ -424,19 +401,17 @@ fun HomePageHeader(
item: BaseItem?, item: BaseItem?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
item?.let { val isEpisode = item?.type == BaseItemKind.EPISODE
val isEpisode = item.type == BaseItemKind.EPISODE val dto = item?.data
val dto = item.data
HomePageHeader( HomePageHeader(
title = item.title, title = item?.title,
subtitle = if (isEpisode) dto.name else null, subtitle = if (isEpisode) dto?.name else null,
overview = dto.overview, overview = dto?.overview,
overviewTwoLines = isEpisode, overviewTwoLines = isEpisode,
quickDetails = item.ui.quickDetails, quickDetails = item?.ui?.quickDetails,
timeRemaining = item.timeRemainingOrRuntime, timeRemaining = item?.timeRemainingOrRuntime,
modifier = modifier, modifier = modifier,
) )
}
} }
@Composable @Composable
@ -445,7 +420,7 @@ fun HomePageHeader(
subtitle: String?, subtitle: String?,
overview: String?, overview: String?,
overviewTwoLines: Boolean, overviewTwoLines: Boolean,
quickDetails: AnnotatedString, quickDetails: AnnotatedString?,
timeRemaining: Duration?, timeRemaining: Duration?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -473,7 +448,7 @@ fun HomePageHeader(
subtitle?.let { subtitle?.let {
EpisodeName(it) EpisodeName(it)
} }
QuickDetails(quickDetails, timeRemaining) QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining)
val overviewModifier = val overviewModifier =
Modifier Modifier
.padding(0.dp) .padding(0.dp)

View file

@ -14,9 +14,11 @@ import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.NavigationManager 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.launchIO
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.setValueOnMain 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.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingExceptionHandler 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -47,6 +48,7 @@ class HomeViewModel
private val datePlayedService: DatePlayedService, private val datePlayedService: DatePlayedService,
private val latestNextUpService: LatestNextUpService, private val latestNextUpService: LatestNextUpService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val userPreferencesService: UserPreferencesService,
) : ViewModel() { ) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending) val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending) val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
@ -57,18 +59,11 @@ class HomeViewModel
init { init {
datePlayedService.invalidateAll() datePlayedService.invalidateAll()
init()
} }
fun init(preferences: UserPreferences): Job { fun init() {
val reload = loadingState.value != LoadingState.Success viewModelScope.launch(
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(
Dispatchers.IO + Dispatchers.IO +
LoadingExceptionHandler( LoadingExceptionHandler(
loadingState, loadingState,
@ -76,10 +71,18 @@ class HomeViewModel
), ),
) { ) {
Timber.d("init 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) { if (reload) {
backdropService.clearBackdrop() backdropService.clearBackdrop()
} }
try {
serverRepository.currentUserDto.value?.let { userDto -> serverRepository.currentUserDto.value?.let { userDto ->
val includedIds = val includedIds =
navDrawerItemRepository navDrawerItemRepository
@ -138,6 +141,14 @@ class HomeViewModel
val loadedLatest = latestNextUpService.loadLatest(latest) val loadedLatest = latestNextUpService.loadLatest(latest)
this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) 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}")
}
}
} }
} }
@ -147,7 +158,7 @@ class HomeViewModel
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setWatched(itemId, played) favoriteWatchManager.setWatched(itemId, played)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
init(preferences) init()
} }
} }
@ -157,7 +168,7 @@ class HomeViewModel
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite) favoriteWatchManager.setFavorite(itemId, favorite)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
init(preferences) init()
} }
} }

View file

@ -279,6 +279,7 @@ fun DestinationContent(
} }
SeerrItemType.PERSON -> { SeerrItemType.PERSON -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
DiscoverPersonPage( DiscoverPersonPage(
person = destination.item, person = destination.item,
modifier = modifier, modifier = modifier,

View file

@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.nav
import android.content.Context import android.content.Context
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState 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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons 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.ArrowDropDown
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.KeyboardArrowLeft 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.material.icons.filled.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind 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.launchIO
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.setValueOnMain 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.spacedByWithFooter
import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.theme.LocalTheme
import com.github.damontecres.wholphin.ui.toServerString 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 com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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 org.jellyfin.sdk.model.api.CollectionType
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -108,15 +104,16 @@ import javax.inject.Inject
class NavDrawerViewModel class NavDrawerViewModel
@Inject @Inject
constructor( constructor(
private val api: ApiClient,
private val navDrawerItemRepository: NavDrawerItemRepository, private val navDrawerItemRepository: NavDrawerItemRepository,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val setupNavigationManager: SetupNavigationManager, val setupNavigationManager: SetupNavigationManager,
val backdropService: BackdropService, val backdropService: BackdropService,
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
) : ViewModel() { ) : ViewModel() {
// private var all: List<NavDrawerItem>? = null
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null) val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf()) val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
val selectedIndex = MutableLiveData(-1) val selectedIndex = MutableLiveData(-1)
val showMore = MutableLiveData(false) val showMore = MutableLiveData(false)
@ -130,7 +127,6 @@ class NavDrawerViewModel
fun init() { fun init() {
viewModelScope.launchIO { viewModelScope.launchIO {
val all = navDrawerItemRepository.getNavDrawerItems() val all = navDrawerItemRepository.getNavDrawerItems()
// this@NavDrawerViewModel.all = all
val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
val moreLibraries = all.toMutableList().apply { removeAll(libraries) } 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) { fun setIndex(index: Int) {
selectedIndex.value = index selectedIndex.value = index
} }
@ -191,6 +218,8 @@ class NavDrawerViewModel
fun setShowMore(value: Boolean) { fun setShowMore(value: Boolean) {
showMore.value = value showMore.value = value
} }
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
} }
sealed interface NavDrawerItem { sealed interface NavDrawerItem {
@ -245,7 +274,7 @@ fun NavDrawer(
viewModel: NavDrawerViewModel = viewModel: NavDrawerViewModel =
hiltViewModel( hiltViewModel(
LocalView.current.findViewTreeViewModelStoreOwner()!!, 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) val drawerState = rememberDrawerState(DrawerValue.Closed)
@ -266,76 +295,14 @@ fun NavDrawer(
LaunchedEffect(Unit) { viewModel.init() } LaunchedEffect(Unit) { viewModel.init() }
val showMore by viewModel.showMore.observeAsState(false) 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 // A negative index is a built in page, >=0 is a library
val selectedIndex by viewModel.selectedIndex.observeAsState(-1) 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) { BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) {
setShowMore(false) viewModel.setShowMore(false)
} }
val onClick = { index: Int, item: NavDrawerItem -> val closedDrawerWidth = NavigationDrawerItemDefaults.CollapsedDrawerItemWidth
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 drawerBackground by animateColorAsState( val drawerBackground by animateColorAsState(
if (drawerState.isOpen) { if (drawerState.isOpen) {
MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surface
@ -377,20 +344,17 @@ fun NavDrawer(
modifier = modifier =
Modifier Modifier
.fillMaxHeight() .fillMaxHeight()
.width(drawerWidth)
.drawBehind { .drawBehind {
drawRect(drawerBackground) drawRect(drawerBackground)
}, },
) { ) {
// Even though some must be clicked, focusing on it should clear other focused items // Even though some must be clicked, focusing on it should clear other focused items
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState() val userImageUrl = remember(user) { viewModel.getUserImage(user) }
LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE } ProfileIcon(
IconNavItem( user = user,
text = user?.name ?: "", imageUrl = userImageUrl,
subtext = server?.name ?: server?.url, serverName = server.name ?: server.url,
icon = Icons.Default.AccountCircle,
selected = false,
drawerOpen = drawerState.isOpen, drawerOpen = drawerState.isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
@ -398,7 +362,7 @@ fun NavDrawer(
SetupDestination.UserList(server), SetupDestination.UserList(server),
) )
}, },
modifier = Modifier.padding(start = drawerPadding), modifier = Modifier,
) )
LazyColumn( LazyColumn(
state = listState, state = listState,
@ -421,13 +385,10 @@ fun NavDrawer(
scrollToSelected() scrollToSelected()
} }
} }
}.fillMaxHeight() }.fillMaxHeight(),
.padding(start = drawerPadding),
) { ) {
item { item {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = -2 }
IconNavItem( IconNavItem(
text = stringResource(R.string.search), text = stringResource(R.string.search),
icon = Icons.Default.Search, icon = Icons.Default.Search,
@ -444,13 +405,11 @@ fun NavDrawer(
.ifElse( .ifElse(
selectedIndex == -2, selectedIndex == -2,
Modifier.focusRequester(focusRequester), Modifier.focusRequester(focusRequester),
).animateItem(), ),
) )
} }
item { item {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = -1 }
IconNavItem( IconNavItem(
text = stringResource(R.string.home), text = stringResource(R.string.home),
icon = Icons.Default.Home, icon = Icons.Default.Home,
@ -470,13 +429,11 @@ fun NavDrawer(
.ifElse( .ifElse(
selectedIndex == -1, selectedIndex == -1,
Modifier.focusRequester(focusRequester), Modifier.focusRequester(focusRequester),
).animateItem(), ),
) )
} }
itemsIndexed(libraries) { index, it -> itemsIndexed(libraries) { index, it ->
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = index }
NavItem( NavItem(
library = it, library = it,
selected = selectedIndex == index, selected = selectedIndex == index,
@ -484,31 +441,26 @@ fun NavDrawer(
drawerOpen = drawerState.isOpen, drawerOpen = drawerState.isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
onClick.invoke(index, it) viewModel.onClickDrawerItem(index, it)
if (it !is NavDrawerItem.More) setShowMore(false)
}, },
modifier = modifier =
Modifier Modifier
.ifElse( .ifElse(
selectedIndex == index, selectedIndex == index,
Modifier.focusRequester(focusRequester), Modifier.focusRequester(focusRequester),
).animateItem(), ),
) )
} }
if (showMore) { if (showMore) {
itemsIndexed(moreLibraries) { index, it -> itemsIndexed(moreLibraries) { index, it ->
val adjustedIndex = (index + libraries.size + 1) val adjustedIndex = (index + libraries.size + 1)
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) {
if (focused) focusedIndex = adjustedIndex
}
NavItem( NavItem(
library = it, library = it,
selected = selectedIndex == adjustedIndex, selected = selectedIndex == adjustedIndex,
moreExpanded = showMore, moreExpanded = showMore,
drawerOpen = drawerState.isOpen, drawerOpen = drawerState.isOpen,
onClick = { onClick.invoke(adjustedIndex, it) }, onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) },
containerColor = containerColor =
if (drawerState.isOpen) { if (drawerState.isOpen) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
@ -521,14 +473,12 @@ fun NavDrawer(
.ifElse( .ifElse(
selectedIndex == adjustedIndex, selectedIndex == adjustedIndex,
Modifier.focusRequester(focusRequester), Modifier.focusRequester(focusRequester),
).animateItem(), ),
) )
} }
} }
item { item {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE }
IconNavItem( IconNavItem(
text = stringResource(R.string.settings), text = stringResource(R.string.settings),
icon = Icons.Default.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 @Composable
fun NavigationDrawerScope.IconNavItem( fun NavigationDrawerScope.IconNavItem(
text: String, text: String,
@ -594,7 +583,7 @@ fun NavigationDrawerScope.IconNavItem(
icon, icon,
contentDescription = null, contentDescription = null,
tint = color, tint = color,
modifier = Modifier.padding(0.dp), modifier = Modifier,
) )
}, },
supportingContent = supportingContent =
@ -630,6 +619,7 @@ fun NavigationDrawerScope.NavItem(
val context = LocalContext.current val context = LocalContext.current
val useFont = library !is ServerNavDrawerItem || library.type != CollectionType.LIVETV val useFont = library !is ServerNavDrawerItem || library.type != CollectionType.LIVETV
val icon = val icon =
remember(library) {
when (library) { when (library) {
NavDrawerItem.Favorites -> { NavDrawerItem.Favorites -> {
R.string.fa_heart R.string.fa_heart
@ -656,6 +646,7 @@ fun NavigationDrawerScope.NavItem(
} }
} }
} }
}
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
NavigationDrawerItem( NavigationDrawerItem(
modifier = modifier, modifier = modifier,
@ -687,13 +678,16 @@ fun NavigationDrawerScope.NavItem(
} }
} }
}, },
trailingContent = { trailingContent =
if (library is NavDrawerItem.More) { if (library is NavDrawerItem.More) {
{
Icon( Icon(
imageVector = if (moreExpanded) Icons.Default.ArrowDropDown else Icons.Default.KeyboardArrowLeft, imageVector = if (moreExpanded) Icons.Default.ArrowDropDown else Icons.Default.KeyboardArrowLeft,
contentDescription = null, contentDescription = null,
) )
} }
} else {
null
}, },
interactionSource = interactionSource, interactionSource = interactionSource,
) { ) {

View file

@ -4,12 +4,14 @@ import com.github.damontecres.wholphin.data.model.Chapter
import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.api.TrickplayInfo
data class CurrentMediaInfo( data class CurrentMediaInfo(
val sourceId: String?,
val videoStream: SimpleVideoStream?,
val audioStreams: List<SimpleMediaStream>, val audioStreams: List<SimpleMediaStream>,
val subtitleStreams: List<SimpleMediaStream>, val subtitleStreams: List<SimpleMediaStream>,
val chapters: List<Chapter>, val chapters: List<Chapter>,
val trickPlayInfo: TrickplayInfo?, val trickPlayInfo: TrickplayInfo?,
) { ) {
companion object { companion object {
val EMPTY = CurrentMediaInfo(listOf(), listOf(), listOf(), null) val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null)
} }
} }

View file

@ -72,7 +72,6 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.components.TextButton
@ -107,16 +106,16 @@ fun PlaybackPage(
preferences: UserPreferences, preferences: UserPreferences,
destination: Destination, destination: Destination,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PlaybackViewModel = hiltViewModel(), viewModel: PlaybackViewModel =
hiltViewModel<PlaybackViewModel, PlaybackViewModel.Factory>(
creationCallback = { it.create(destination) },
),
) { ) {
LifecycleStartEffect(destination) { LifecycleStartEffect(destination) {
onStopOrDispose { onStopOrDispose {
viewModel.release() viewModel.release()
} }
} }
LaunchedEffect(destination) {
viewModel.init(destination, preferences)
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val st = loading) { when (val st = loading) {
@ -131,12 +130,34 @@ fun PlaybackPage(
} }
LoadingState.Success -> { LoadingState.Success -> {
val playerState by viewModel.currentPlayer.collectAsState()
PlaybackPageContent(
playerState = playerState!!,
preferences = preferences,
destination = destination,
viewModel = viewModel,
modifier = modifier,
)
}
}
}
@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 prefs = preferences.appPreferences.playbackPreferences val prefs = preferences.appPreferences.playbackPreferences
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val density = LocalDensity.current val density = LocalDensity.current
val player = viewModel.player
val mediaInfo by viewModel.currentMediaInfo.observeAsState() val mediaInfo by viewModel.currentMediaInfo.observeAsState()
val userDto by viewModel.currentUserDto.observeAsState() val userDto by viewModel.currentUserDto.observeAsState()
@ -160,8 +181,8 @@ fun PlaybackPage(
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language) val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) } var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
OneTimeLaunchedEffect { LaunchedEffect(player) {
if (prefs.playerBackend == PlayerBackend.MPV) { if (playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.IO + ExceptionHandler()) { scope.launch(Dispatchers.IO + ExceptionHandler()) {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
configuration, configuration,
@ -170,10 +191,11 @@ fun PlaybackPage(
} }
} }
} }
AmbientPlayerListener(player) AmbientPlayerListener(player)
var contentScale by remember { var contentScale by remember(playerBackend) {
mutableStateOf( mutableStateOf(
if (prefs.playerBackend == PlayerBackend.MPV) { if (playerBackend == PlayerBackend.MPV) {
ContentScale.FillBounds ContentScale.FillBounds
} else { } else {
prefs.globalContentScale.scale prefs.globalContentScale.scale
@ -402,7 +424,7 @@ fun PlaybackPage(
setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f) setBottomPaddingFraction(it.margin.toFloat() / 100f)
} }
viewModel.assHandler?.let { assHandler -> playerState.assHandler?.let { assHandler ->
if (prefs.overrides.directPlayAss) { if (prefs.overrides.directPlayAss) {
Timber.v("Adding AssSubtitleView") Timber.v("Adding AssSubtitleView")
addView( addView(
@ -622,6 +644,4 @@ fun PlaybackPage(
enableVideoScale = player !is MpvPlayer, enableVideoScale = player !is MpvPlayer,
) )
} }
}
}
} }

View file

@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.services.PlaylistCreationResult
import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -58,7 +59,6 @@ import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import com.github.damontecres.wholphin.util.ExceptionHandler 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.LoadingState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.checkForSupport 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.profile.Codec
import com.github.damontecres.wholphin.util.subtitleMimeTypes import com.github.damontecres.wholphin.util.subtitleMimeTypes
import com.github.damontecres.wholphin.util.supportItemKinds 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.peerless2012.ass.media.AssHandler 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.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage import org.jellyfin.sdk.model.api.PlaystateMessage
import org.jellyfin.sdk.model.api.TrickplayInfo 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.inWholeTicks
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -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) * 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]) @OptIn(markerClass = [UnstableApi::class])
class PlaybackViewModel class PlaybackViewModel
@Inject @AssistedInject
constructor( constructor(
@param:ApplicationContext internal val context: Context, @param:ApplicationContext internal val context: Context,
internal val api: ApiClient, internal val api: ApiClient,
@ -133,15 +137,20 @@ class PlaybackViewModel
private val deviceProfileService: DeviceProfileService, private val deviceProfileService: DeviceProfileService,
private val refreshRateService: RefreshRateService, private val refreshRateService: RefreshRateService,
val streamChoiceService: StreamChoiceService, val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService,
@Assisted private val destination: Destination,
) : ViewModel(), ) : ViewModel(),
Player.Listener, Player.Listener,
AnalyticsListener { AnalyticsListener {
var assHandler: AssHandler? = null @AssistedFactory
val player by lazy { interface Factory {
val creation = playerFactory.createVideoPlayer() fun create(destination: Destination): PlaybackViewModel
assHandler = creation.assHandler
creation.player
} }
val currentPlayer = MutableStateFlow<PlayerState?>(null)
internal lateinit var player: Player
private var mediaSession: MediaSession? = null private var mediaSession: MediaSession? = null
internal val mutex = Mutex() internal val mutex = Mutex()
@ -178,7 +187,14 @@ class PlaybackViewModel
val currentUserDto = serverRepository.currentUserDto val currentUserDto = serverRepository.currentUserDto
init { init {
addCloseable { viewModelScope.launchIO {
addCloseable { disconnectPlayer() }
init()
}
}
private fun disconnectPlayer() {
if (this@PlaybackViewModel::player.isInitialized) {
player.removeListener(this@PlaybackViewModel) player.removeListener(this@PlaybackViewModel)
(player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel) (player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel)
@ -186,26 +202,72 @@ class PlaybackViewModel
it.release() it.release()
player.removeListener(it) player.removeListener(it)
} }
jobs.forEach { it.cancel() }
player.release() player.release()
mediaSession?.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.addListener(this)
(player as? ExoPlayer)?.addAnalyticsListener(this) (player as? ExoPlayer)?.addAnalyticsListener(this)
jobs.add(subscribe()) jobs.add(subscribe())
jobs.add(listenForTranscodeReason()) jobs.add(listenForTranscodeReason())
val sessionPlayer =
MediaSessionPlayer(
player,
controllerViewState,
preferences.appPreferences.playbackPreferences,
)
mediaSession =
MediaSession
.Builder(context, sessionPlayer)
.build()
} }
/** /**
* Initialize from the UI to start playback * Initialize from the UI to start playback
*/ */
fun init( private suspend fun init() {
destination: Destination, nextUp.setValueOnMain(null)
preferences: UserPreferences, this.preferences = userPreferencesService.getCurrent()
) {
nextUp.value = null
this.preferences = preferences
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
addCloseable { refreshRateService.resetRefreshRate() } addCloseable { refreshRateService.resetRefreshRate() }
} }
@ -238,13 +300,6 @@ class PlaybackViewModel
} }
} }
this.itemId = itemId this.itemId = itemId
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error preparing for playback for $itemId",
),
) {
val queriedItem = api.userLibraryApi.getItem(itemId).content val queriedItem = api.userLibraryApi.getItem(itemId).content
val base = val base =
if (queriedItem.type.playable) { if (queriedItem.type.playable) {
@ -263,14 +318,14 @@ class PlaybackViewModel
when (val r = playlistResult) { when (val r = playlistResult) {
is PlaylistCreationResult.Error -> { is PlaylistCreationResult.Error -> {
loading.setValueOnMain(LoadingState.Error(r.message, r.ex)) loading.setValueOnMain(LoadingState.Error(r.message, r.ex))
return@launch return
} }
is PlaylistCreationResult.Success -> { is PlaylistCreationResult.Success -> {
if (r.playlist.items.isEmpty()) { if (r.playlist.items.isEmpty()) {
showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
navigationManager.goBack() navigationManager.goBack()
return@launch return
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = r.playlist this@PlaybackViewModel.playlist.value = r.playlist
@ -284,19 +339,9 @@ class PlaybackViewModel
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
} }
val sessionPlayer = viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
MediaSessionPlayer(
player,
controllerViewState,
preferences.appPreferences.playbackPreferences,
)
mediaSession =
MediaSession
.Builder(context, sessionPlayer)
.build()
val item = BaseItem.from(base, api) val item = BaseItem.from(base, api)
val played = val played =
play( play(
item, item,
@ -317,7 +362,6 @@ class PlaybackViewModel
} }
} }
} }
}
/** /**
* Play an item * Play an item
@ -384,6 +428,21 @@ class PlaybackViewModel
return@withContext false 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 = val subtitleStreams =
mediaSource.mediaStreams mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE } ?.filter { it.type == MediaStreamType.SUBTITLE }
@ -454,6 +513,8 @@ class PlaybackViewModel
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
updateCurrentMedia { updateCurrentMedia {
CurrentMediaInfo( CurrentMediaInfo(
sourceId = mediaSource.id,
videoStream = videoStream,
audioStreams = audioStreams, audioStreams = audioStreams,
subtitleStreams = subtitleStreams, subtitleStreams = subtitleStreams,
chapters = chapters, chapters = chapters,
@ -493,71 +554,18 @@ class PlaybackViewModel
enableDirectStream: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding,
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
val itemId = item.id val itemId = item.id
val playerBackend = preferences.appPreferences.playbackPreferences.playerBackend
val currentPlayback = this@PlaybackViewModel.currentPlayback.value val currentPlayback = this@PlaybackViewModel.currentPlayback.value
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) { if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
// If direct playing, can try to switch tracks without playback restarting val wasSuccessful =
// Except for external subtitles changeStreamsDirectPlay(
// TODO there's probably no reason why we can't add external subtitles? currentPlayback = currentPlayback,
Timber.v("changeStreams direct play") currentItemPlayback = currentItemPlayback,
audioIndex = audioIndex,
val source = currentPlayback.mediaSourceInfo subtitleIndex = subtitleIndex,
val externalSubtitle = source.findExternalSubtitle(subtitleIndex) userInitiated = userInitiated,
if (externalSubtitle == null) {
val result =
withContext(Dispatchers.Main) {
TrackSelectionUtils.createTrackSelections(
onMain { player.trackSelectionParameters },
onMain { player.currentTracks },
playerBackend,
true,
audioIndex,
subtitleIndex,
source,
) )
} if (wasSuccessful) return@withContext
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")
}
} }
Timber.d( Timber.d(
@ -575,7 +583,7 @@ class PlaybackViewModel
PlaybackInfoDto( PlaybackInfoDto(
startTimeTicks = null, startTimeTicks = null,
deviceProfile = deviceProfile =
if (playerBackend == PlayerBackend.EXO_PLAYER) { if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) {
deviceProfileService.getOrCreateDeviceProfile( deviceProfileService.getOrCreateDeviceProfile(
preferences.appPreferences.playbackPreferences, preferences.appPreferences.playbackPreferences,
serverRepository.currentServer.value?.serverVersion, serverRepository.currentServer.value?.serverVersion,
@ -680,7 +688,7 @@ class PlaybackViewModel
CurrentPlayback( CurrentPlayback(
item = item, item = item,
tracks = listOf(), tracks = listOf(),
backend = preferences.appPreferences.playbackPreferences.playerBackend, backend = currentPlayer.value!!.backend,
playMethod = transcodeType, playMethod = transcodeType,
playSessionId = response.playSessionId, playSessionId = response.playSessionId,
liveStreamId = source.liveStreamId, liveStreamId = source.liveStreamId,
@ -731,7 +739,7 @@ class PlaybackViewModel
TrackSelectionUtils.createTrackSelections( TrackSelectionUtils.createTrackSelections(
player.trackSelectionParameters, player.trackSelectionParameters,
player.currentTracks, player.currentTracks,
playerBackend, currentPlayer.value!!.backend,
source.supportsDirectPlay, source.supportsDirectPlay,
audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY },
subtitleIndex, 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) { fun changeAudioStream(index: Int) {
viewModelScope.launchIO { viewModelScope.launchIO {
Timber.d("Changing audio track to %s", index) Timber.d("Changing audio track to %s", index)
@ -1118,9 +1201,7 @@ class PlaybackViewModel
fun release() { fun release() {
Timber.v("release") Timber.v("release")
activityListener?.release() disconnectPlayer()
player.release()
mediaSession?.release()
activityListener = null activityListener = null
} }
@ -1296,3 +1377,9 @@ class PlaybackViewModel
} }
} }
} }
data class PlayerState(
val player: Player,
val backend: PlayerBackend,
val assHandler: AssHandler?,
)

View file

@ -23,3 +23,9 @@ data class SimpleMediaStream(
) )
} }
} }
data class SimpleVideoStream(
val index: Int,
val hdr: Boolean,
val is4k: Boolean,
)

View file

@ -164,7 +164,9 @@ object TrackSelectionUtils {
} }
// TODO MPV could use literal indexes because they are stored in the track format ID // TODO MPV could use literal indexes because they are stored in the track format ID
PlayerBackend.MPV -> { PlayerBackend.PREFER_MPV,
PlayerBackend.MPV,
-> {
when (type) { when (type) {
MediaStreamType.VIDEO -> { MediaStreamType.VIDEO -> {
serverIndex - externalSubtitleCount + 1 serverIndex - externalSubtitleCount + 1

View file

@ -23,6 +23,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation 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.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -171,6 +173,8 @@ fun <T> ComposablePreference(
is AppChoicePreference -> { is AppChoicePreference -> {
val values = stringArrayResource(preference.displayValues).toList() val values = stringArrayResource(preference.displayValues).toList()
val subtitles =
preference.subtitles?.let { stringArrayResource(preference.subtitles).toList() }
val summary = val summary =
preference.summary?.let { stringResource(it) } preference.summary?.let { stringResource(it) }
?: preference.summary(context, value) ?: preference.summary(context, value)
@ -188,25 +192,32 @@ fun <T> ComposablePreference(
fromLongClick = false, fromLongClick = false,
items = items =
values.mapIndexed { index, it -> values.mapIndexed { index, it ->
DialogItem(
headlineContent = {
Text(it)
},
leadingContent = {
if (index == selectedIndex) { if (index == selectedIndex) {
DialogItem( Icon(
text = it, imageVector = Icons.Default.Done,
icon = Icons.Default.Done, contentDescription = "selected",
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
)
} else {
DialogItem(
text = it,
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
) )
} }
}, },
supportingContent = {
subtitles?.let {
val text = subtitles[index]
if (text.isNotNullOrBlank()) {
Text(text)
}
}
},
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
)
},
) )
}, },
interactionSource = interactionSource, interactionSource = interactionSource,

View file

@ -36,6 +36,8 @@ enum class PreferenceScreenOption {
ADVANCED, ADVANCED,
USER_INTERFACE, USER_INTERFACE,
SUBTITLES, SUBTITLES,
EXO_PLAYER,
MPV,
; ;
companion object { companion object {

View file

@ -43,9 +43,10 @@ import androidx.tv.material3.surfaceColorAtElevation
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.imageLoader import coil3.imageLoader
import com.github.damontecres.wholphin.R 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.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences 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.PlayerBackend
import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences import com.github.damontecres.wholphin.preferences.basicPreferences
@ -87,6 +88,7 @@ fun PreferencesContent(
val state = rememberLazyListState() val state = rememberLazyListState()
var preferences by remember { mutableStateOf(initialPreferences) } var preferences by remember { mutableStateOf(initialPreferences) }
val currentUser by viewModel.currentUser.observeAsState() val currentUser by viewModel.currentUser.observeAsState()
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
var showPinFlow by remember { mutableStateOf(false) } var showPinFlow by remember { mutableStateOf(false) }
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
@ -125,6 +127,8 @@ fun PreferencesContent(
PreferenceScreenOption.ADVANCED -> advancedPreferences PreferenceScreenOption.ADVANCED -> advancedPreferences
PreferenceScreenOption.USER_INTERFACE -> uiPreferences PreferenceScreenOption.USER_INTERFACE -> uiPreferences
PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
PreferenceScreenOption.MPV -> MpvPreferences
} }
val screenTitle = val screenTitle =
when (preferenceScreenOption) { when (preferenceScreenOption) {
@ -132,6 +136,8 @@ fun PreferencesContent(
PreferenceScreenOption.ADVANCED -> R.string.advanced_settings PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface
PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style 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) } var visible by remember { mutableStateOf(false) }
@ -476,7 +482,7 @@ fun PreferencesContent(
SeerrDialogMode.Remove -> { SeerrDialogMode.Remove -> {
ConfirmDialog( ConfirmDialog(
title = stringResource(R.string.remove_seerr_server), title = stringResource(R.string.remove_seerr_server),
body = "", body = currentServer?.url ?: "",
onCancel = { seerrDialogMode = SeerrDialogMode.None }, onCancel = { seerrDialogMode = SeerrDialogMode.None },
onConfirm = { onConfirm = {
seerrVm.removeServer() seerrVm.removeServer()
@ -498,13 +504,7 @@ fun PreferencesContent(
AddSeerServerDialog( AddSeerServerDialog(
currentUsername = currentUser?.name, currentUsername = currentUser?.name,
status = status, status = status,
onSubmit = { url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod -> onSubmit = seerrVm::submitServer,
if (method == SeerrAuthMethod.API_KEY) {
seerrVm.submitServer(url, passwordOrApiKey)
} else {
seerrVm.submitServer(url, username ?: "", passwordOrApiKey, method)
}
},
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
) )
} }
@ -527,6 +527,8 @@ fun PreferencesPage(
PreferenceScreenOption.BASIC, PreferenceScreenOption.BASIC,
PreferenceScreenOption.ADVANCED, PreferenceScreenOption.ADVANCED,
PreferenceScreenOption.USER_INTERFACE, PreferenceScreenOption.USER_INTERFACE,
PreferenceScreenOption.EXO_PLAYER,
PreferenceScreenOption.MPV,
-> { -> {
PreferencesContent( PreferencesContent(
initialPreferences, initialPreferences,

View file

@ -181,9 +181,10 @@ fun ServerList(
@Composable @Composable
fun rememberIdColor( fun rememberIdColor(
id: UUID?, id: UUID?,
alpha: Float = 1f,
nullColor: Color = MaterialTheme.colorScheme.surfaceVariant, nullColor: Color = MaterialTheme.colorScheme.surfaceVariant,
): Color = ): Color =
remember(id) { remember(id, alpha) {
if (id == null) { if (id == null) {
return@remember nullColor return@remember nullColor
} }
@ -212,6 +213,7 @@ fun rememberIdColor(
red = (r + m).coerceIn(0f, 1f), red = (r + m).coerceIn(0f, 1f),
green = (g + m).coerceIn(0f, 1f), green = (g + m).coerceIn(0f, 1f),
blue = (b + m).coerceIn(0f, 1f), blue = (b + m).coerceIn(0f, 1f),
alpha = alpha,
) )
} }

View file

@ -118,13 +118,11 @@ fun SwitchUserContent(
users = users, users = users,
currentUser = currentUser, currentUser = currentUser,
onSwitchUser = { user -> onSwitchUser = { user ->
// TODO PIN-related if (user.hasPin) {
// if (user.pin.isNotNullOrBlank()) { switchUserWithPin = user
// switchUserWithPin = user } else {
// } else {
// viewModel.switchUser(user)
// }
viewModel.switchUser(user) viewModel.switchUser(user)
}
}, },
onAddUser = { showAddUser = true }, onAddUser = { showAddUser = true },
onRemoveUser = { user -> onRemoveUser = { user ->

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.setup package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border import androidx.tv.material3.Border
import androidx.tv.material3.Button 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.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome 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.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus 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 * 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 * Add User card component - displays a + icon in a circle
*/ */

View file

@ -6,10 +6,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -30,6 +32,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec 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.EditTextBox
import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -240,7 +243,7 @@ fun AddSeerrServerUsername(
}, },
), ),
isInputValid = { true }, isInputValid = { true },
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(usernameFocusRequester),
) )
} }
Row( Row(
@ -283,13 +286,24 @@ fun AddSeerrServerUsername(
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
} }
TextButton( Button(
stringRes = R.string.submit,
onClick = { onSubmit.invoke(url, username, password) }, 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), 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),
) )
} }
}
}
} }
@PreviewTvSpec @PreviewTvSpec

View file

@ -17,7 +17,7 @@ import com.github.damontecres.wholphin.util.LoadingState
fun AddSeerServerDialog( fun AddSeerServerDialog(
currentUsername: String?, currentUsername: String?,
status: LoadingState, status: LoadingState,
onSubmit: (url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) } var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) }
@ -49,7 +49,7 @@ fun AddSeerServerDialog(
) { ) {
AddSeerrServerApiKey( AddSeerrServerApiKey(
onSubmit = { url, apiKey -> onSubmit = { url, apiKey ->
onSubmit.invoke(url, null, apiKey, SeerrAuthMethod.API_KEY) onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY)
}, },
status = status, status = status,
) )

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.setup.seerr package com.github.damontecres.wholphin.ui.setup.seerr
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException 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.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.inject.Inject import jakarta.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber import timber.log.Timber
@HiltViewModel @HiltViewModel
class SwitchSeerrViewModel class SwitchSeerrViewModel
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
private val seerrService: SeerrService, private val seerrService: SeerrService,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
) : ViewModel() { ) : ViewModel() {
val currentUser = serverRepository.currentUser val currentUser = serverRepository.currentUser
val currentSeerrServer = seerrServerRepository.currentServer
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending) val serverConnectionStatus = MutableStateFlow<LoadingState>(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( fun submitServer(
url: String, url: String,
username: String, username: String,
password: String, passwordOrApiKey: String,
authMethod: SeerrAuthMethod, authMethod: SeerrAuthMethod,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val url = cleanUrl(url) serverConnectionStatus.update { LoadingState.Loading }
val result = val urls =
try {
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 { try {
seerrServerRepository.testConnection( seerrServerRepository.testConnection(
authMethod = authMethod, authMethod = authMethod,
url = url, url = url.toString(),
username = username, username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY },
passwordOrApiKey = password, passwordOrApiKey = passwordOrApiKey,
) )
} catch (ex: ClientException) { } catch (ex: ClientException) {
Timber.w(ex, "Error logging in via %s", authMethod) Timber.w(ex, "ClientException logging in")
if (ex.statusCode == 401 || ex.statusCode == 403) { if (ex.statusCode == 401 || ex.statusCode == 403) {
LoadingState.Error("Invalid credentials", ex) showToast(context, "Invalid credentials")
result = LoadingState.Error("Invalid credentials", ex)
break
} else { } else {
LoadingState.Error("Could not connect with URL")
}
} catch (ex: Exception) {
Timber.w(ex, "Exception logging in")
LoadingState.Error(ex) LoadingState.Error(ex)
} }
}
if (result is LoadingState.Success) { if (result is LoadingState.Success) {
seerrServerRepository.addAndChangeServer(url, authMethod, username, password) 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.Error) {
showToast(context, "Error: ${result.message}")
} }
serverConnectionStatus.update { result } serverConnectionStatus.update { result }
} }
@ -112,3 +114,39 @@ class SwitchSeerrViewModel
serverConnectionStatus.update { LoadingState.Pending } serverConnectionStatus.update { LoadingState.Pending }
} }
} }
fun createUrls(url: String): List<HttpUrl> {
val urls = mutableListOf<String>()
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
}

View file

@ -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<FilterValueOption> =
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<Int>()
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()
}
}
}

View file

@ -8,8 +8,10 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.TimeFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.time.LocalDateTime import java.time.LocalDateTime
val LocalClock = compositionLocalOf<Clock> { Clock() } val LocalClock = compositionLocalOf<Clock> { Clock() }
@ -32,6 +34,7 @@ data class Clock(
fun ProvideLocalClock(content: @Composable () -> Unit) { fun ProvideLocalClock(content: @Composable () -> Unit) {
val clock = remember { Clock() } val clock = remember { Clock() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
while (isActive) { while (isActive) {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val time = TimeFormatter.format(now) val time = TimeFormatter.format(now)
@ -40,5 +43,6 @@ fun ProvideLocalClock(content: @Composable () -> Unit) {
delay(2_000) delay(2_000)
} }
} }
}
CompositionLocalProvider(LocalClock provides clock, content) CompositionLocalProvider(LocalClock provides clock, content)
} }

View file

@ -39,17 +39,9 @@ object MPVLib {
external fun create(appctx: Context) external fun create(appctx: Context)
fun initialize() { external fun init()
synchronized(this) { init() }
}
private external fun init() external fun destroy()
fun tearDown() {
synchronized(this) { destroy() }
}
private external fun destroy()
external fun attachSurface(surface: Surface) external fun attachSurface(surface: Surface)

View file

@ -74,6 +74,8 @@ class MpvPlayer(
SurfaceHolder.Callback { SurfaceHolder.Callback {
companion object { companion object {
private const val DEBUG = false private const val DEBUG = false
private val initLock = Any()
} }
private var surface: Surface? = null private var surface: Surface? = null
@ -131,7 +133,7 @@ class MpvPlayer(
MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}")
Timber.v("Initializing MPVLib") Timber.v("Initializing MPVLib")
MPVLib.initialize() MPVLib.init()
MPVLib.setOptionString("force-window", "no") MPVLib.setOptionString("force-window", "no")
MPVLib.setOptionString("idle", "yes") MPVLib.setOptionString("idle", "yes")
@ -1086,17 +1088,21 @@ class MpvPlayer(
} }
MpvCommand.INITIALIZE -> { MpvCommand.INITIALIZE -> {
synchronized(initLock) {
init() init()
} }
}
MpvCommand.DESTROY -> { MpvCommand.DESTROY -> {
clearVideoSurfaceView(null) synchronized(initLock) {
MPVLib.setPropertyBoolean("pause", true)
MPVLib.removeLogObserver(mpvLogger) MPVLib.removeLogObserver(mpvLogger)
MPVLib.tearDown() MPVLib.destroy()
Timber.d("MPVLib destroyed") Timber.d("MPVLib destroyed")
} }
} }
} }
}
} }
fun MPVLib.setPropertyColor( fun MPVLib.setPropertyColor(

View file

@ -32,6 +32,7 @@ enum MediaExtensionStatus{
enum PlayerBackend{ enum PlayerBackend{
EXO_PLAYER = 0; EXO_PLAYER = 0;
MPV = 1; MPV = 1;
PREFER_MPV = 2;
} }
message MpvOptions{ message MpvOptions{

View file

@ -0,0 +1,456 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="about">O aplikaci</string>
<string name="active_recordings">Aktivní nahrávání</string>
<string name="add_favorite">Oblíbené</string>
<string name="add_server">Přidat server</string>
<string name="add_user">Přidat uživatele</string>
<string name="audio">Zvuk</string>
<string name="birthplace">Místo narození</string>
<string name="bitrate">Bitrate</string>
<string name="born">Narozen</string>
<string name="cancel_recording">Zrušit nahrávání</string>
<string name="cancel_series_recording">Zrušit nahrávání seriálu</string>
<string name="cancel">Zrušit</string>
<string name="chapters">Kapitoly</string>
<string name="choose_stream">Vybrat %1$s</string>
<string name="clear_image_cache">Vymazat mezipaměť obrázků</string>
<string name="collection">Sbírka</string>
<string name="collections">Kolekce</string>
<string name="community_rating">Hodnocení Komunity</string>
<string name="compose_error_message_title">Došlo k chybě! Stiskněte tlačítko pro odeslání protokolů na server.</string>
<string name="confirm">Potvrdit</string>
<string name="continue_watching">Pokračovat ve sledování</string>
<string name="critic_rating">Hodnocení kritiků</string>
<string name="decimal_seconds">%.1f sekund</string>
<string name="default_track">Výchozí</string>
<string name="delete">Smazat</string>
<string name="died">Zemřel</string>
<string name="directed_by">Režie %1$s</string>
<string name="director">Režisér</string>
<string name="disabled">Vypnuto</string>
<string name="discovered_servers">Nalezené Servery</string>
<string name="downloading">Stahování…</string>
<string name="enabled">Zapnuto</string>
<string name="ends_at">Konec v %1$s</string>
<string name="enter_server_url">Vložte Server IP nebo URL</string>
<string name="enter_server_address">Vložte adresu serveru</string>
<string name="episodes">Epizody</string>
<string name="error_loading_collection">Chyba při načítání kolekce %1$s</string>
<string name="external_track">Externí</string>
<string name="favorites">Oblíbený</string>
<string name="file_size">Velikost</string>
<string name="forced_track">Nucené</string>
<string name="genres">Žánry</string>
<string name="go_to">Přejít do</string>
<string name="hide_controller_timeout">Skrýt ovládací prvky přehrávání</string>
<string name="go_to_series">Přejít na sérii</string>
<string name="hide_debug_info">Skrýt informace o ladění</string>
<string name="hide">Skrýt</string>
<string name="home">Domů</string>
<string name="intro">Úvod</string>
<string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string>
<string name="library">Knihovna</string>
<string name="license_info">Informace o licenci</string>
<string name="live_tv">Živá TV</string>
<string name="loading">Načítání…</string>
<string name="mark_entire_series_as_played">Označit celou sérii jako zhlédnutou?</string>
<string name="mark_entire_series_as_unplayed">Označit celou sérii jako nezhlédnuté?</string>
<string name="mark_unwatched">Označit jako nezhlédnuté</string>
<string name="mark_watched">Označit jako zhlédnuté</string>
<string name="max_bitrate">Maximální bitrate</string>
<string name="more_like_this">Více podobných</string>
<string name="more">Více</string>
<plurals name="movies">
<item quantity="one">Filmy</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<string name="name">Jméno</string>
<string name="next_up">Další v pořadí</string>
<string name="no_data">Žádná data</string>
<string name="no_results">Žádné výsledky</string>
<string name="no_servers_found">Nenalezeny žádné servery</string>
<string name="no_scheduled_recordings">Žádné naplánované nahrávání</string>
<string name="no_update_available">Žádné aktualizace nejsou k dispozici</string>
<string name="none">Žádný</string>
<string name="only_forced_subtitles">Pouze vynucené titulky</string>
<string name="outro">Závěr</string>
<string name="path">Cesta</string>
<plurals name="people">
<item quantity="one">Lidé</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<string name="play_count">Počet přehrání</string>
<string name="play_from_here">Přehrát odtud</string>
<string name="play">Přehrát</string>
<string name="playback_debug_info">Zobrazit informace o ladění přehrávání</string>
<string name="playback_speed">Rychlost přehrávání</string>
<string name="playback">Přehrávání</string>
<string name="playlist">Seznam</string>
<string name="playlists">Seznamy</string>
<string name="preview">Náhled</string>
<string name="profile_specific_settings">Nastavení uživatelského profilu</string>
<string name="queue">Fronta</string>
<string name="recap">Shrnout</string>
<string name="recently_added_in">Nedávno přidáno v %1$s</string>
<string name="recently_added">Nedávno přidáno</string>
<string name="recently_recorded">Nedávno nahráno</string>
<string name="recently_released">Nedávno vydané</string>
<string name="recommended">Doporučeno</string>
<string name="record_program">Nahrát Program</string>
<string name="record_series">Nahrát Seriál</string>
<string name="remove_favorite">Odebrat z oblíbených</string>
<string name="restart">Restart</string>
<string name="resume">Pokračovat</string>
<string name="save">Uložit</string>
<string name="search">Hledat</string>
<string name="searching">Vyhledávání…</string>
<string name="voice_search">Hlasové vyhledávání</string>
<string name="voice_starting">Začátek</string>
<string name="voice_search_prompt">Hledat hlasem</string>
<string name="processing">Zpracování</string>
<string name="press_back_to_cancel">Zrušíte stisknutím tlačítka zpět</string>
<string name="voice_error_audio">Chyba nahrávání zvuku</string>
<string name="voice_error_client">Chyba rozpoznávání hlasu</string>
<string name="voice_error_permissions">Vyžadováno oprávnění k přístupu k mikrofonu</string>
<string name="voice_error_network">Chyba sítě</string>
<string name="voice_error_network_timeout">Časový limit sítě</string>
<string name="voice_error_no_match">Řeč nerozpoznaná</string>
<string name="voice_error_busy">Rozpoznávání hlasu je zaneprázdněno</string>
<string name="voice_error_server">Chyba serveru</string>
<string name="voice_error_speech_timeout">Nebyla detekována žádná řeč</string>
<string name="voice_error_unknown">Neznámá chyba</string>
<string name="voice_error_start_failed">Nepodařilo se spustit rozpoznávání hlasu</string>
<string name="voice_error_timeout">Časový limit pro rozpoznávání hlasu vypršel</string>
<string name="retry">Zkusit znovu</string>
<string name="select_server">Vybrat server</string>
<string name="select_user">Vybrat Uživatele</string>
<string name="show_debug_info">Zobrazit informace o ladění</string>
<string name="show_next_up_when">Zobrazit další</string>
<string name="show">Ukázat</string>
<string name="shuffle">Náhodně</string>
<string name="skip">Přeskočit</string>
<string name="sort_by_date_added">Datum přidání</string>
<string name="sort_by_date_episode_added">Datum přidání epizody</string>
<string name="sort_by_date_played">Datum přehraní</string>
<string name="sort_by_date_released">Datum vydání</string>
<string name="sort_by_name">Jméno</string>
<string name="sort_by_random">Náhodný</string>
<string name="studios">Studia</string>
<string name="submit">Odeslat</string>
<string name="subtitle_download_too_long">Stahování trvá dlouho, možná budete muset restartovat přehrávání</string>
<string name="subtitle">Podnázev</string>
<string name="subtitles">Titulky</string>
<string name="suggestions">Návrhy</string>
<string name="switch_servers">Přepnout servery</string>
<string name="switch_user">Přepnout</string>
<string name="top_unwatched">Nejlépe hodnocené Nezhlédnuté</string>
<string name="trailer">Upoutávka</string>
<plurals name="trailers">
<item quantity="one">Upoutávky</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<string name="tv_dvr_schedule">Harmonogram nahrávání</string>
<string name="tv_guide">Televizní program</string>
<string name="tv_season">Sezóna</string>
<string name="tv_seasons">Sezóny</string>
<plurals name="tv_shows">
<item quantity="one">Seriály</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<string name="ui_interface">Prostředí</string>
<string name="unknown">Neznámý</string>
<string name="updates">Aktualizace</string>
<string name="version">Verze</string>
<string name="video_scale">Měřítko videa</string>
<string name="video">Video</string>
<string name="videos">Videa</string>
<string name="watch_live">Sledujte živě</string>
<string name="years_old">%1$d let starý</string>
<string name="play_with_transcoding">Přehraj s překódováním</string>
<string name="media_information">Informace o médiu</string>
<string name="show_clock">Zobrazit hodiny</string>
<string name="aired_episode_order">Pořadí odvysílaných epizod</string>
<string name="create_playlist">Vytvořit nový playlist</string>
<string name="add_to_playlist">Přidat do playlistu</string>
<string name="one_click_pause">Pozastavení jedním kliknutím</string>
<string name="one_click_pause_summary_on">Stisknutím středu D-Padu pozastavíte/přehrajete</string>
<string name="italic_font">Kurzíva</string>
<string name="font">Font</string>
<string name="background">Pozadí</string>
<string name="success">Úspěch</string>
<string name="official_rating">Rodičovské hodnocení</string>
<string name="extras">Doplňky</string>
<plurals name="other_extras">
<item quantity="one">Jiný</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="behind_the_scenes">
<item quantity="one">V zákulisí</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="theme_songs">
<item quantity="one">Znělky</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="theme_videos">
<item quantity="one">Tématická videa</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="clips">
<item quantity="one">Klipy</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="deleted_scenes">
<item quantity="one">Vymazané scény</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="interviews">
<item quantity="one">Rozhovory</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="scenes">
<item quantity="one">Scény</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="featurettes">
<item quantity="one">Krátkometrážní filmy</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="hours">
<item quantity="one">%d hodina</item>
<item quantity="few">%d hodin</item>
<item quantity="many">%d hodin</item>
<item quantity="other">%d hodin</item>
</plurals>
<plurals name="items">
<item quantity="one">%d položka</item>
<item quantity="few">%d položky</item>
<item quantity="many">%d položek</item>
<item quantity="other">%d položek</item>
</plurals>
<plurals name="seconds">
<item quantity="one">%d sekunda</item>
<item quantity="few">%d sekundy</item>
<item quantity="many">%d sekund</item>
<item quantity="other">%d sekund</item>
</plurals>
<string name="ac3_supported">Zařízení podporuje AC3/Dolby Digital</string>
<string name="advanced_settings">Pokročilá Nastavení</string>
<string name="advanced_ui">Pokročilé UI</string>
<string name="app_theme">Vzhled Aplikace</string>
<string name="auto_check_for_updates">Automaticky kontrolovat aktualizace</string>
<string name="auto_play_next_delay">Zpoždění před dalším přehráním</string>
<string name="auto_play_next">Další přehrát automaticky</string>
<string name="check_for_updates">Zkontrolovat aktualizace</string>
<string name="combine_continue_next_summary">Platí pouze pro Seriály</string>
<string name="downmix_stereo">Vždy downmixovat do stereo formátu</string>
<string name="ffmpeg_extension_pref">Použijte modul dekodéru FFmpeg</string>
<string name="global_content_scale">Výchozí měřítko obsahu</string>
<string name="install_update">Nainstalovat aktualizaci</string>
<string name="installed_version">Nainstalovaná verze</string>
<string name="max_homepage_items">Maximální počet položek v řádcích domovské stránky</string>
<string name="nav_drawer_pins_summary">Vyberte výchozí položky, které se mají zobrazit, ostatní budou skryty</string>
<string name="nav_drawer_pins">Přizpůsobení položek navigačního panelu</string>
<string name="nav_drawer_switch_on_focus_summary_off">Kliknutím přepnete stránky</string>
<string name="nav_drawer_switch_on_focus">Přepnout stránky navigačního panelu při zaostření</string>
<string name="pass_out_protection">Ochrana proti výpadku</string>
<string name="play_theme_music">Přehrát tematickou hudbu</string>
<string name="playback_overrides">Přepsání přehrávání</string>
<string name="remember_selected_tab">Zapamatovat vybrané karty</string>
<string name="rewatch_next_up">Povolit opakované sledování v dalším videu</string>
<string name="seek_bar_steps">Kroky Seek baru</string>
<string name="send_app_logs_summary">Užitečné pro ladění</string>
<string name="send_app_logs">Odeslat protokoly aplikací na aktuální server</string>
<string name="send_crash_reports_summary">Pokusím se odeslat na poslední připojený server</string>
<string name="send_crash_reports">Odesílat zprávy o selhání</string>
<string name="settings">Nastavení</string>
<string name="skip_back_on_resume_preference">Přeskočit zpět při obnovení přehrávání</string>
<string name="skip_back_preference">Přeskočit zpět</string>
<string name="skip_commercials_behavior">Přeskakovat reklamy</string>
<string name="skip_forward_preference">Přeskočit vpřed</string>
<string name="skip_intro_behavior">Chování přeskočení úvodu</string>
<string name="skip_outro_behavior">Přeskočit chování konce</string>
<string name="skip_previews_behavior">Chování přeskočení náhledů</string>
<string name="update_available">Aktualizace k dispozici</string>
<string name="update_url_summary">URL adresa používaná ke kontrole aktualizací aplikace</string>
<string name="update_url">Aktualizovat URL</string>
<string name="font_size">Velikost písma</string>
<string name="font_color">Barva písma</string>
<string name="font_opacity">Průhlednost písma</string>
<string name="edge_style">Styl okraje</string>
<string name="edge_color">Barva okraje</string>
<string name="background_opacity">Průhlednost pozadí</string>
<string name="background_style">Styl pozadí</string>
<string name="subtitle_style">Styl titulků</string>
<string name="background_color">Barva pozadí</string>
<string name="reset">Reset</string>
<string name="bold_font">Tučné písmo</string>
<string name="mpv_hardware_decoding">MPV: Použití hardwarového dekódování</string>
<string name="disable_if_crash">Zakažte, pokud dojde k pádům</string>
<string name="mpv_options">Možnosti MPV</string>
<string name="exoplayer_options">Možnosti ExoPlayeru</string>
<string name="skip_segment_unknown">Přeskočit segment</string>
<string name="skip_segment_commercial">Přeskočit reklamy</string>
<string name="skip_segment_preview">Přeskočit náhled</string>
<string name="skip_segment_recap">Přeskočit shrnutí</string>
<string name="skip_recap_behavior">Chování přeskočení shrnutí</string>
<string name="skip_segment_outro">Přeskočit konec</string>
<string name="skip_segment_intro">Přeskočit úvod</string>
<string name="played">Přehráno</string>
<string name="filter">Filtr</string>
<string name="year">Rok</string>
<string name="decade">Desetiletí</string>
<string name="remove">Odstranit</string>
<string name="dolby_vision">DolbyVision</string>
<string name="dolby_atmos">Atmos</string>
<string name="mpv_use_gpu_next">MPV: Použití gpu-next</string>
<string name="mpv_conf">Upravit mpv.conf</string>
<string name="discard_change">Zahodit změny?</string>
<string name="subtitle_margin">Okraj</string>
<string name="verbose_logging">Podrobné protokolování</string>
<string name="enter_pin">Zadejte PIN</string>
<string name="sign_in_auto">Automatické přihlášení</string>
<string name="use_server_credentials">Přihlášení přes server</string>
<string name="press_enter_to_confirm">Stiskněte středové tlačítko pro potvrzení</string>
<string name="require_pin_code">Vyžadovat PIN pro profil</string>
<string name="confirm_pin">Potvrdit PIN</string>
<string name="incorrect">Nesprávný</string>
<string name="pin_too_short">PIN musí mít 4 znaky nebo více</string>
<string name="will_remove_pin">PIN bude odstraněn</string>
<string name="image_cache_size">Velikost mezipaměti (MB)</string>
<string name="view_options">Možnosti zobrazení</string>
<string name="columns">Sloupce</string>
<string name="spacing">Mezery</string>
<string name="aspect_ratio">Poměr stran</string>
<string name="show_details">Zobrazit podrobnosti</string>
<string name="image_type">Typ obrázku</string>
<string name="guest_stars">Hostující hvězdy</string>
<string name="edge_size">Velikost okraje</string>
<string name="refresh_rate_switching">Přepínání obnovovací frekvence</string>
<string name="automatic">Automaticky</string>
<string name="username_or_password">Použijte uživatelské jméno/heslo</string>
<string name="login">Přihlášení</string>
<string name="general">Obecné</string>
<string name="container">Kontejner</string>
<string name="title">Titul</string>
<string name="codec">Kodek</string>
<string name="profile">Profil</string>
<string name="level">Úroveň</string>
<string name="resolution">Rozlišení</string>
<string name="framerate">Snímková frekvence</string>
<string name="bit_depth">Bitová hloubka</string>
<string name="video_range">Rozsah videa</string>
<string name="video_range_type">Typ rozsahu videa</string>
<string name="color_space">Barevný prostor</string>
<string name="color_transfer">Přenos barev</string>
<string name="color_primaries">Základní barvy</string>
<string name="pixel_format">Formát pixelů</string>
<string name="ref_frames">Referenční snímky</string>
<string name="nal">NAL</string>
<string name="language">Jazyk</string>
<string name="layout">Rozložení</string>
<string name="channels">Kanály</string>
<string name="sample_rate">Vzorkovací frekvence</string>
<string name="avc">AVC</string>
<string name="yes">Ano</string>
<string name="no">Ne</string>
<string name="sdr">SDR</string>
<string name="hdr">HDR</string>
<string name="hdr10">HDR10</string>
<string name="hdr10_plus">HDR10+</string>
<string name="hlg">HLG</string>
<string name="bit_unit">bit</string>
<string name="sample_rate_unit">Hz</string>
<string name="show_titles">Zobrazit názvy</string>
<string name="live_tv_repeat">Opakovat</string>
<string name="favorite_channels_at_beginning">Zobrazit oblíbené kanály jako první</string>
<string name="sort_channels_recently_watched">Řadit kanály podle naposledy sledovaných</string>
<string name="subtitle_delay">Zpoždění titulků</string>
<string name="backdrop_display">Styl prospektu</string>
<string name="resolution_switching">Přepínání rozlišení</string>
<string name="local">Místní</string>
<string name="play_trailer">Přehrát upoutávku</string>
<string name="no_trailers">Žádné upoutávky</string>
<string name="clear_track_choices">Vyčistit výběr skladeb</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="truehd">True HD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
<string name="dts">DTS</string>
<string name="force_dovi_profile_7">Direct play Dolby Vision Profil 7</string>
<string name="force_dovi_profile_7_summary">Ignoruj kontrolu kompatibility zařízení</string>
<string name="discover">Najít</string>
<string name="request">Žádost</string>
<string name="pending">Čeká na vyřízení</string>
<string name="seerr_integration">Seerr integrace</string>
<string name="remove_seerr_server">Odstranit Seerr Server</string>
<string name="seerr_server_added">Seerr server přidán</string>
<string name="password">Heslo</string>
<string name="username">Uživatelské jméno</string>
<string name="url">URL</string>
<string name="upcoming_movies">Nadcházející filmy</string>
<string name="trending">Trendy</string>
<string name="upcoming_tv">Nadcházející TV pořady</string>
<string name="request_4k">Žádost ve 4K</string>
<string name="software_decoding_av1">Softwarové dekódování AV1</string>
<string name="commercial">Komerční</string>
<string name="download_and_update"><![CDATA[Download & Update]]></string>
<string name="immediate">Bezprostředně</string>
<string name="search_and_download"><![CDATA[Search & Download]]></string>
<string name="runtime_sort">Trvání</string>
<plurals name="samples">
<item quantity="one">Vzorky</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="shorts">
<item quantity="one">Krátká videa</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="downloads">
<item quantity="one">%s Stažen</item>
<item quantity="few">%s Staženo</item>
<item quantity="many">%s Stažení</item>
<item quantity="other">%s Stažení</item>
</plurals>
<string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string>
<string name="direct_play_ass">Direct play ASS titulky</string>
<string name="direct_play_pgs">Direct play PGS titulky</string>
<string name="player_backend">Přehrávání Backendu</string>
<string name="cast_and_crew"><![CDATA[Cast & Crew]]></string>
<string name="anamorphic">Anamorfní</string>
<string name="interlaced">Prokládané</string>
<string name="color_code_programs">Color code programs</string>
<string name="upgrade_mpv_toast">MPV je nyní výchozím přehrávačem mimo HDR.\nToto můžete změnit v nastavení.</string>
</resources>

View file

@ -2,7 +2,7 @@
<resources> <resources>
<string name="about">Über diese App</string> <string name="about">Über diese App</string>
<string name="active_recordings">Aktive Aufnahmen</string> <string name="active_recordings">Aktive Aufnahmen</string>
<string name="add_favorite">Favoriten</string> <string name="add_favorite">Zu Favoriten hinzufügen</string>
<string name="add_server">Server hinzufügen</string> <string name="add_server">Server hinzufügen</string>
<string name="add_user">Benutzer hinzufügen</string> <string name="add_user">Benutzer hinzufügen</string>
<string name="audio">Ton</string> <string name="audio">Ton</string>
@ -56,7 +56,7 @@
<string name="tv_shows">Serien</string> <string name="tv_shows">Serien</string>
<string name="ui_interface">Oberfläche</string> <string name="ui_interface">Oberfläche</string>
<string name="unknown">Unbekannt</string> <string name="unknown">Unbekannt</string>
<string name="updates">Updates</string> <string name="updates">Aktualisierungen</string>
<string name="version">Version</string> <string name="version">Version</string>
<string name="watch_live">Live ansehen</string> <string name="watch_live">Live ansehen</string>
<string name="years_old">%1$d Jahre alt</string> <string name="years_old">%1$d Jahre alt</string>
@ -81,12 +81,12 @@
<string name="auto_check_for_updates">Automatisch auf Aktualisierungen prüfen</string> <string name="auto_check_for_updates">Automatisch auf Aktualisierungen prüfen</string>
<string name="check_for_updates">Auf Aktualisierungen prüfen</string> <string name="check_for_updates">Auf Aktualisierungen prüfen</string>
<string name="settings">Einstellungen</string> <string name="settings">Einstellungen</string>
<string name="choose_stream">Wählen %1$s</string> <string name="choose_stream">%1$s Wählen</string>
<string name="clear_image_cache">Bilder Cache leeren</string> <string name="clear_image_cache">Bilder Cache leeren</string>
<string name="community_rating">Community Bewertung</string> <string name="community_rating">Community Bewertung</string>
<string name="compose_error_message_title">Es ist ein Fehler aufgetreten! Drücke den Knopf um Logs zu deinem Server zu senden.</string> <string name="compose_error_message_title">Es ist ein Fehler aufgetreten! Drücke den Knopf um Logs zu deinem Server zu senden.</string>
<string name="directed_by">Regie von %1$s</string> <string name="directed_by">Regie von %1$s</string>
<string name="disabled">Deaktivert</string> <string name="disabled">Deaktiviert</string>
<string name="discovered_servers">Gefundene Server</string> <string name="discovered_servers">Gefundene Server</string>
<string name="downloading">Herunterladen…</string> <string name="downloading">Herunterladen…</string>
<string name="enabled">Aktiviert</string> <string name="enabled">Aktiviert</string>
@ -118,7 +118,7 @@
<string name="next_up">Als nächstes</string> <string name="next_up">Als nächstes</string>
<string name="none">Keine</string> <string name="none">Keine</string>
<string name="play_from_here">Von hier wiedergeben</string> <string name="play_from_here">Von hier wiedergeben</string>
<string name="play">Ansehen</string> <string name="play">Wiedergeben</string>
<string name="playback_speed">Wiedergabegeschwindigkeit</string> <string name="playback_speed">Wiedergabegeschwindigkeit</string>
<string name="playback">Wiedergabe</string> <string name="playback">Wiedergabe</string>
<string name="preview">Vorschau</string> <string name="preview">Vorschau</string>
@ -143,7 +143,7 @@
<string name="subtitle_download_too_long">Der Download benötigt eine längere Zeit, evtl. musst du die Wiedergabe erneut starten</string> <string name="subtitle_download_too_long">Der Download benötigt eine längere Zeit, evtl. musst du die Wiedergabe erneut starten</string>
<string name="subtitle">Untertitel</string> <string name="subtitle">Untertitel</string>
<string name="suggestions">Vorschläge</string> <string name="suggestions">Vorschläge</string>
<string name="switch_user">Wechsel</string> <string name="switch_user">Wechseln</string>
<string name="top_unwatched">Top Bewertet Ungesehen</string> <string name="top_unwatched">Top Bewertet Ungesehen</string>
<string name="tv_guide">Fernsehprogramm</string> <string name="tv_guide">Fernsehprogramm</string>
<string name="play_with_transcoding">Mit Transkodierung abspielen</string> <string name="play_with_transcoding">Mit Transkodierung abspielen</string>
@ -207,12 +207,12 @@
<item quantity="other"/> <item quantity="other"/>
</plurals> </plurals>
<plurals name="clips"> <plurals name="clips">
<item quantity="one">Clips</item> <item quantity="one">Clip</item>
<item quantity="other"/> <item quantity="other">Clips</item>
</plurals> </plurals>
<plurals name="interviews"> <plurals name="interviews">
<item quantity="one">Interviews</item> <item quantity="one">Interview</item>
<item quantity="other"/> <item quantity="other">Interviews</item>
</plurals> </plurals>
<plurals name="samples"> <plurals name="samples">
<item quantity="one">Beispiele</item> <item quantity="one">Beispiele</item>
@ -321,4 +321,67 @@
<string name="no">Nein</string> <string name="no">Nein</string>
<string name="show_titles">Titel anzeigen</string> <string name="show_titles">Titel anzeigen</string>
<string name="live_tv_repeat">Wiederholen</string> <string name="live_tv_repeat">Wiederholen</string>
<string name="general">Generell</string>
<string name="no_trailers">Keine Trailer</string>
<string name="play_trailer">Trailer abspielen</string>
<string name="local">Lokal</string>
<string name="verbose_logging">detaillierte Protokollierung</string>
<string name="use_server_credentials">Login via Server</string>
<string name="press_enter_to_confirm">Drücke die mittlere Taste zur Bestätigung</string>
<string name="image_cache_size">Größe des Festplatten-Caches für Bilder (MB)</string>
<string name="cast_and_crew">Besetzung &amp; Mitwirkende</string>
<string name="container">Container</string>
<string name="codec">Codec</string>
<string name="level">Level</string>
<string name="bit_depth">Bittiefe</string>
<string name="avc">AVC</string>
<string name="sdr">SDR</string>
<string name="hdr">HDR</string>
<string name="hdr10">HDR10</string>
<string name="hdr10_plus">HDR10+</string>
<string name="hlg">HLG</string>
<string name="bit_unit">bit</string>
<string name="sample_rate_unit">Hz</string>
<string name="only_forced_subtitles">Nur erzwungene Untertitel</string>
<string name="voice_starting">Beginne</string>
<string name="processing">verarbeite</string>
<string name="voice_error_permissions">Mikrofonzugriff erforderlich</string>
<string name="voice_error_network">Netzwerkfehler</string>
<string name="voice_error_network_timeout">Netzwerk Zeitüberschreitung</string>
<string name="voice_error_server">Serverfehler</string>
<string name="voice_error_unknown">unbekannter Fehler</string>
<string name="retry">wiederholen</string>
<string name="layout">🇩🇪 Layout</string>
<string name="sample_rate">Abtastrate</string>
<string name="dts">DTS</string>
<string name="discover">Entdecke</string>
<string name="request">Anfrage</string>
<string name="pending">Ausstehend</string>
<string name="seerr_integration">Seerr Integration</string>
<string name="seerr_server_added">Seerr server hinzugefügt</string>
<string name="password">Passwort</string>
<string name="username">Benutzername</string>
<string name="url">URL</string>
<string name="request_4k">In 4K anfragen</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
<string name="trending">Im Trend</string>
<string name="voice_error_start_failed">Starten der Spracherkennung fehlgeschlagen</string>
<string name="press_back_to_cancel">Zurück wählen um abzubrechen</string>
<string name="upgrade_mpv_toast">MPV ist jetzt der Standard-Player, außer für HDR.\nDas kannst du in den Einstellungen ändern.</string>
<string name="voice_search">Sprachsuche</string>
<string name="voice_search_prompt">Sprechen, um zu suchen</string>
<string name="force_dovi_profile_7_summary">Ignoriert geräte kompatibilität</string>
<string name="upcoming_movies">kommende Filme</string>
<string name="upcoming_tv">kommende Serien</string>
<string name="force_dovi_profile_7">Direktes abspielen von Dolby Vision Profil 7</string>
<string name="subtitle_delay">Untertitelverzögerung</string>
<string name="sort_channels_recently_watched">Sortiere Kanäle nach zuletzt geschaut</string>
<string name="favorite_channels_at_beginning">Favoriten als erstes anzeigen</string>
<string name="backdrop_display">Hintergrundstil</string>
<string name="refresh_rate_switching">Bildwiederholraten-Anpassung</string>
<string name="voice_error_speech_timeout">Keine Spracheingabe erkannt</string>
</resources> </resources>

View file

@ -179,9 +179,9 @@
<item quantity="other"/> <item quantity="other"/>
</plurals> </plurals>
<plurals name="clips"> <plurals name="clips">
<item quantity="one">Clips</item> <item quantity="one">Clip</item>
<item quantity="many"/> <item quantity="many">Clips</item>
<item quantity="other"/> <item quantity="other"></item>
</plurals> </plurals>
<plurals name="deleted_scenes"> <plurals name="deleted_scenes">
<item quantity="one">Escenas eliminadas</item> <item quantity="one">Escenas eliminadas</item>
@ -375,4 +375,50 @@
<string name="color_code_programs">Programas codificados por colores</string> <string name="color_code_programs">Programas codificados por colores</string>
<string name="subtitle_delay">Retardo de subtítulos</string> <string name="subtitle_delay">Retardo de subtítulos</string>
<string name="backdrop_display">Estilo del fondo</string> <string name="backdrop_display">Estilo del fondo</string>
<string name="only_forced_subtitles">Solo Subtítulos Forzados</string>
<string name="voice_search">Búsqueda por voz</string>
<string name="voice_starting">Iniciando</string>
<string name="voice_search_prompt">Habla para buscar</string>
<string name="processing">Procesando</string>
<string name="press_back_to_cancel">Pulse atrás para cancelar</string>
<string name="voice_error_audio">Error en la grabación de audio</string>
<string name="voice_error_client">Error de reconocimiento de voz</string>
<string name="voice_error_permissions">Se requiere permiso para el micrófono</string>
<string name="voice_error_network">Error de red</string>
<string name="voice_error_network_timeout">Tiempo de espera agotado de red</string>
<string name="voice_error_no_match">No se reconoce el habla</string>
<string name="voice_error_busy">Reconocimiento de voz ocupado</string>
<string name="voice_error_server">Error de servidor</string>
<string name="voice_error_speech_timeout">No se detecta voz</string>
<string name="voice_error_unknown">Error desconocido</string>
<string name="voice_error_start_failed">No se pudo iniciar el reconocimiento de voz</string>
<string name="voice_error_timeout">Se agotó el tiempo de espera para el reconocimiento de voz</string>
<string name="retry">Reintentar</string>
<string name="resolution_switching">Cambio de resolución</string>
<string name="local">Local</string>
<string name="play_trailer">Reproducir trailer</string>
<string name="no_trailers">Sin trailers</string>
<string name="clear_track_choices">Opciones de pista claras</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
<string name="dts">DTS</string>
<string name="force_dovi_profile_7">Reproducción directa Dolby Vision Profile 7</string>
<string name="force_dovi_profile_7_summary">Ignora las comprobaciones de compatibilidad del dispositivo</string>
<string name="discover">Descubrir</string>
<string name="request">Solicitar</string>
<string name="pending">Pendiente</string>
<string name="seerr_integration">Integración con Seerr</string>
<string name="remove_seerr_server">Eliminar servidor Seerr</string>
<string name="seerr_server_added">Servidor Seerr añadido</string>
<string name="password">Contraseña</string>
<string name="username">Usuario</string>
<string name="url">URL</string>
<string name="trending">Tendencias</string>
<string name="upcoming_movies">Próximos estrenos</string>
<string name="upcoming_tv">Próximos programas de televisión</string>
<string name="request_4k">Solicitar en 4K</string>
<string name="software_decoding_av1">Decodificación por software AV1</string>
</resources> </resources>

View file

@ -359,4 +359,51 @@
<string name="no_servers_found">Ühtegi serverit ei leidu</string> <string name="no_servers_found">Ühtegi serverit ei leidu</string>
<string name="backdrop_display">Tausta dekoratsioon</string> <string name="backdrop_display">Tausta dekoratsioon</string>
<string name="ends_at">Lõppeb kell %1$s</string> <string name="ends_at">Lõppeb kell %1$s</string>
<string name="resolution_switching">Resolutsiooni vahetamine</string>
<string name="local">Kohalik</string>
<string name="play_trailer">Esita treilerit</string>
<string name="no_trailers">Treilerit pole</string>
<string name="clear_track_choices">Eemalda rajavalikud</string>
<string name="only_forced_subtitles">Vaid sundkorras kuvatud subtiitrid</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
<string name="force_dovi_profile_7">Dolby Vision Profile 7 otseesitusega</string>
<string name="force_dovi_profile_7_summary">Eirab seadme ühilduvuskontrolle</string>
<string name="dts">DTS</string>
<string name="discover">Otsi ja leia</string>
<string name="request">Päring</string>
<string name="pending">Ootel</string>
<string name="seerr_integration">Seerri lõiming</string>
<string name="remove_seerr_server">Eemalda Seerri server</string>
<string name="password">Salasõna</string>
<string name="username">Kasutajanimi</string>
<string name="url">Võrguaadress</string>
<string name="trending">Populaarsust koguv</string>
<string name="upcoming_movies">Lähiajal avaldatavad filmid</string>
<string name="upcoming_tv">Lähiajal avaldatavad telesarjad ja -saated</string>
<string name="request_4k">Küsi 4K versiooni</string>
<string name="seerr_server_added">Seerri server on lisatud</string>
<string name="voice_search">Häälotsing</string>
<string name="voice_starting">Alustan</string>
<string name="voice_search_prompt">Otsimiseks kõnele</string>
<string name="processing">Töötlen</string>
<string name="press_back_to_cancel">Katkestamiseks vajuta Tagasi-nuppu</string>
<string name="voice_error_audio">Heli salvestamise viga</string>
<string name="voice_error_client">Kõnetuvastamise viga</string>
<string name="voice_error_permissions">Vajalik on õigus kasutada mikrofoni</string>
<string name="voice_error_network">Võrguühenduse viga</string>
<string name="voice_error_network_timeout">Võrguühenduse aegumine</string>
<string name="voice_error_no_match">Pole midagi kõneldut, mida tuvastada</string>
<string name="voice_error_busy">Kõnetuvastus on hõivatud</string>
<string name="voice_error_server">Serveri viga</string>
<string name="voice_error_speech_timeout">Pole tuvastatavat kõnelemist</string>
<string name="voice_error_unknown">Tundmatu viga</string>
<string name="voice_error_start_failed">Kõnetuvastuse käivitamine ei õnnestunud</string>
<string name="voice_error_timeout">Kõnetuvastus aegus</string>
<string name="retry">Proovi uuesti</string>
<string name="software_decoding_av1">AV1 tarkvaraline dekodeerimine</string>
<string name="upgrade_mpv_toast">MPV on nüüd vaikimisi meediaesitaja kõige v.a. HDR-i jaoks.\nSeda saad muuta seadistustest.</string>
</resources> </resources>

View file

@ -11,7 +11,7 @@
<string name="delete">Supprimer</string> <string name="delete">Supprimer</string>
<string name="director">Réalisateur</string> <string name="director">Réalisateur</string>
<string name="directed_by">Réalisé par %1$s</string> <string name="directed_by">Réalisé par %1$s</string>
<string name="episodes">Episodes</string> <string name="episodes">Épisodes</string>
<string name="active_recordings">Enregistrements en cours</string> <string name="active_recordings">Enregistrements en cours</string>
<string name="birthplace">Lieu de naissance</string> <string name="birthplace">Lieu de naissance</string>
<string name="born">Né(e)</string> <string name="born">Né(e)</string>
@ -197,7 +197,7 @@
<string name="global_content_scale">Échelle de contenu par défaut</string> <string name="global_content_scale">Échelle de contenu par défaut</string>
<string name="install_update">Installer la mise à jour</string> <string name="install_update">Installer la mise à jour</string>
<string name="installed_version">Version installée</string> <string name="installed_version">Version installée</string>
<string name="max_homepage_items">Nombre déléments sur les lignes de la page d\'accueil</string> <string name="max_homepage_items">Nombre maximal d\'éléments par ligne sur la page d\'accueil</string>
<string name="nav_drawer_pins_summary">Choisissez les éléments à afficher par défaut, les autres seront masqués</string> <string name="nav_drawer_pins_summary">Choisissez les éléments à afficher par défaut, les autres seront masqués</string>
<string name="nav_drawer_pins">Personnaliser les éléments du menu de navigation</string> <string name="nav_drawer_pins">Personnaliser les éléments du menu de navigation</string>
<string name="nav_drawer_switch_on_focus_summary_off">Cliquez pour changer de page</string> <string name="nav_drawer_switch_on_focus_summary_off">Cliquez pour changer de page</string>
@ -323,7 +323,7 @@
<string name="spacing">Espacement</string> <string name="spacing">Espacement</string>
<string name="aspect_ratio">Format d\'image</string> <string name="aspect_ratio">Format d\'image</string>
<string name="show_details">Afficher les détails</string> <string name="show_details">Afficher les détails</string>
<string name="cast_and_crew"><![CDATA[Distribution et équipe]]></string> <string name="cast_and_crew">Distribution et équipe</string>
<string name="guest_stars">Invités spéciaux</string> <string name="guest_stars">Invités spéciaux</string>
<string name="edge_size">Taille des bordures</string> <string name="edge_size">Taille des bordures</string>
<string name="refresh_rate_switching">Changement de fréquence de rafraîchissement</string> <string name="refresh_rate_switching">Changement de fréquence de rafraîchissement</string>
@ -375,4 +375,51 @@
<string name="no_servers_found">Aucun serveur trouvé</string> <string name="no_servers_found">Aucun serveur trouvé</string>
<string name="subtitle_delay">Décalage des sous-titres</string> <string name="subtitle_delay">Décalage des sous-titres</string>
<string name="backdrop_display">Style d\'arrière-plan</string> <string name="backdrop_display">Style d\'arrière-plan</string>
<string name="clear_track_choices">Effacer les choix de piste</string>
<string name="resolution_switching">Changement de résolution</string>
<string name="local">Local</string>
<string name="play_trailer">Lire la bande-annonce</string>
<string name="no_trailers">Pas de bandes-annonces</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
<string name="dts">DTS</string>
<string name="only_forced_subtitles">Sous-titres forcés uniquement</string>
<string name="force_dovi_profile_7">Lecture directe Dolby Vision Profile 7</string>
<string name="force_dovi_profile_7_summary">Ignore les vérifications de compatibilité des appareils</string>
<string name="voice_search">Recherche vocale</string>
<string name="voice_starting">Démarrage</string>
<string name="voice_search_prompt">Parler pour rechercher</string>
<string name="processing">Traitement</string>
<string name="press_back_to_cancel">Appuyez sur Retour pour annuler</string>
<string name="voice_error_audio">Erreur d\'enregistrement audio</string>
<string name="voice_error_client">Erreur de reconnaissance vocale</string>
<string name="voice_error_permissions">Autorisation du microphone requise</string>
<string name="voice_error_network">Erreur réseau</string>
<string name="voice_error_network_timeout">Délai d\'expiration du réseau</string>
<string name="voice_error_no_match">Aucune voix reconnue</string>
<string name="voice_error_busy">Reconnaissance vocale occupée</string>
<string name="voice_error_server">Erreur serveur</string>
<string name="voice_error_speech_timeout">Aucune voix détectée</string>
<string name="voice_error_unknown">Erreur inconnue</string>
<string name="voice_error_start_failed">Échec du démarrage de la reconnaissance vocale</string>
<string name="voice_error_timeout">Délai d\'attente de la reconnaissance vocale dépassé</string>
<string name="retry">Réessayer</string>
<string name="discover">Découvrir</string>
<string name="request">Demande</string>
<string name="pending">En attente</string>
<string name="seerr_integration">Intégration Seerr</string>
<string name="remove_seerr_server">Supprimer le serveur Seerr</string>
<string name="seerr_server_added">Serveur Seerr ajouté</string>
<string name="password">Mot de passe</string>
<string name="username">Nom d\'utilisateur</string>
<string name="url">URL</string>
<string name="trending">Tendances</string>
<string name="upcoming_movies">Films à venir</string>
<string name="upcoming_tv">Séries à venir</string>
<string name="request_4k">Demande en 4K</string>
<string name="software_decoding_av1">Décodage logiciel AV1</string>
<string name="upgrade_mpv_toast">MPV est désormais le lecteur par défaut, sauf pour HDR.\nVous pouvez modifier cela dans les paramètres.</string>
</resources> </resources>

View file

@ -11,7 +11,7 @@
<string name="cancel">Mégse</string> <string name="cancel">Mégse</string>
<string name="chapters">Fejezetek</string> <string name="chapters">Fejezetek</string>
<string name="choose_stream">%1$s kiválasztása</string> <string name="choose_stream">%1$s kiválasztása</string>
<string name="clear_image_cache">Képgyorsítótár törlése</string> <string name="clear_image_cache">Kép-gyorsítótár törlése</string>
<string name="collection">Gyűjtemény</string> <string name="collection">Gyűjtemény</string>
<string name="collections">Gyűjtemények</string> <string name="collections">Gyűjtemények</string>
<string name="community_rating">Közösségi értékelés</string> <string name="community_rating">Közösségi értékelés</string>
@ -233,7 +233,7 @@
<string name="global_content_scale">Tartalom alapértelmezett méretezése</string> <string name="global_content_scale">Tartalom alapértelmezett méretezése</string>
<string name="install_update">Frissítés telepítése</string> <string name="install_update">Frissítés telepítése</string>
<string name="installed_version">Telepített verzió</string> <string name="installed_version">Telepített verzió</string>
<string name="max_homepage_items">Maximális elemek soronként a kezdőlapon</string> <string name="max_homepage_items">Elemek száma soronként a kezdőlapon</string>
<string name="nav_drawer_pins_summary">Válassza ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve</string> <string name="nav_drawer_pins_summary">Válassza ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve</string>
<string name="nav_drawer_pins">Navigációs menü elemeinek testreszabása</string> <string name="nav_drawer_pins">Navigációs menü elemeinek testreszabása</string>
<string name="nav_drawer_switch_on_focus_summary_off">Kattintson az oldalak közti váltáshoz</string> <string name="nav_drawer_switch_on_focus_summary_off">Kattintson az oldalak közti váltáshoz</string>
@ -279,7 +279,7 @@
<string name="skip_segment_recap">Összefoglaló átugrása</string> <string name="skip_segment_recap">Összefoglaló átugrása</string>
<string name="skip_segment_outro">Stáblista átugrása</string> <string name="skip_segment_outro">Stáblista átugrása</string>
<string name="skip_segment_intro">Intró átugrása</string> <string name="skip_segment_intro">Intró átugrása</string>
<string name="played">Lejátszott</string> <string name="played">Megnézett</string>
<string name="filter">Szűrő</string> <string name="filter">Szűrő</string>
<string name="year">Év</string> <string name="year">Év</string>
<string name="decade">Évtized</string> <string name="decade">Évtized</string>
@ -355,8 +355,20 @@
<string name="enter_server_address">Adja meg a szerver címét</string> <string name="enter_server_address">Adja meg a szerver címét</string>
<string name="combine_continue_next"><![CDATA[Folytatás & Következő menü összevonása]]></string> <string name="combine_continue_next"><![CDATA[Folytatás & Következő menü összevonása]]></string>
<string name="player_backend">Lejátszás backend</string> <string name="player_backend">Lejátszás backend</string>
<string name="image_cache_size">Kép gyorsítótár méret (MB)</string> <string name="image_cache_size">Kép-gyorsítótár mérete (MB)</string>
<string name="cast_and_crew"><![CDATA[Stábtagok]]></string> <string name="cast_and_crew"><![CDATA[Stábtagok]]></string>
<string name="nav_drawer_switch_on_focus">Navigációs menü oldalainak váltása fókusznál</string> <string name="nav_drawer_switch_on_focus">Navigációs menü oldalainak váltása fókusznál</string>
<string name="playback_overrides">Lejátszás felülbírálatai</string> <string name="playback_overrides">Lejátszás felülbírálatai</string>
<string name="resolution_switching">Felbontásváltás</string>
<string name="play_trailer">Előzetes lejátszása</string>
<string name="no_trailers">Nincsenek előzetesek</string>
<string name="local">Helyi</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS-HD</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
<string name="dts">DTS</string>
<string name="only_forced_subtitles">Csak kiegészítő felirat</string>
<string name="clear_track_choices">Sávválasztás törlése</string>
</resources> </resources>

View file

@ -121,9 +121,9 @@
<string name="switch_servers">Ganti Server</string> <string name="switch_servers">Ganti Server</string>
<string name="switch_user">Ganti</string> <string name="switch_user">Ganti</string>
<string name="top_unwatched">Terbaik Belum Ditonton</string> <string name="top_unwatched">Terbaik Belum Ditonton</string>
<string name="trailer">Cuplikan</string> <string name="trailer">Trailer</string>
<plurals name="trailers"> <plurals name="trailers">
<item quantity="other">Cuplikan</item> <item quantity="other">Trailer</item>
</plurals> </plurals>
<string name="tv_dvr_schedule">Jadwal DVR</string> <string name="tv_dvr_schedule">Jadwal DVR</string>
<string name="tv_guide">Panduan</string> <string name="tv_guide">Panduan</string>
@ -291,11 +291,11 @@
<string name="guest_stars">Pemeran Tamu</string> <string name="guest_stars">Pemeran Tamu</string>
<string name="anamorphic">Anamorfik</string> <string name="anamorphic">Anamorfik</string>
<string name="interlaced">Interlace</string> <string name="interlaced">Interlace</string>
<string name="framerate">Framerate</string> <string name="framerate">Laju frame</string>
<string name="bit_depth">Kedalaman Bit</string> <string name="bit_depth">Kedalaman bit</string>
<string name="video_range">Rentang Video</string> <string name="video_range">Rentang video</string>
<string name="video_range_type">Jenis rentang video</string> <string name="video_range_type">Jenis rentang video</string>
<string name="color_space">Color space</string> <string name="color_space">Ruang warna</string>
<string name="pixel_format">Format piksel</string> <string name="pixel_format">Format piksel</string>
<string name="nal">NAL</string> <string name="nal">NAL</string>
<string name="language">Bahasa</string> <string name="language">Bahasa</string>
@ -336,4 +336,57 @@
<string name="enter_server_address">Masukkan alamat server</string> <string name="enter_server_address">Masukkan alamat server</string>
<string name="no_servers_found">Server tidak ditemukan</string> <string name="no_servers_found">Server tidak ditemukan</string>
<string name="subtitle_delay">Penundaan subtitel</string> <string name="subtitle_delay">Penundaan subtitel</string>
<string name="voice_search">Penelusuran suara</string>
<string name="voice_starting">Memulai</string>
<string name="voice_search_prompt">Ucapkan untuk mencari</string>
<string name="processing">Memproses</string>
<string name="press_back_to_cancel">Tekan kembali untuk membatalkan</string>
<string name="voice_error_audio">Error perekaman audio</string>
<string name="voice_error_client">Error pengenalan suara</string>
<string name="voice_error_permissions">Diperlukan izin akses mikrofon</string>
<string name="voice_error_network">Kesalahan jaringan</string>
<string name="voice_error_network_timeout">Koneksi jaringan terputus</string>
<string name="voice_error_no_match">Suara tidak dikenali</string>
<string name="voice_error_busy">Pengenalan suara sedang memproses</string>
<string name="voice_error_server">Gangguan server</string>
<string name="voice_error_speech_timeout">Tidak ada ucapan terdeteksi</string>
<string name="voice_error_unknown">Terjadi kesalahan yang tidak diketahui</string>
<string name="voice_error_start_failed">Gagal memulai pengenalan suara</string>
<string name="voice_error_timeout">Batas waktu pengenalan suara habis</string>
<string name="retry">Coba lagi</string>
<string name="only_forced_subtitles">Hanya Forced Subtitel</string>
<string name="refresh_rate_switching">Peralihan refresh rate</string>
<string name="color_transfer">Transfer warna</string>
<string name="color_primaries">Primari warna</string>
<string name="ref_frames">Frame referensi</string>
<string name="layout">Tata letak</string>
<string name="channels">Saluran</string>
<string name="backdrop_display">Gaya backdrop</string>
<string name="resolution_switching">Peralihan resolusi</string>
<string name="local">Lokal</string>
<string name="play_trailer">Putar trailer</string>
<string name="no_trailers">Tidak ada trailer</string>
<string name="clear_track_choices">Hapus pilihan trek</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
<string name="dts">DTS</string>
<string name="force_dovi_profile_7">Putar langsung Dolby Vision Profil 7</string>
<string name="force_dovi_profile_7_summary">Abaikan pemeriksaan kompatibilitas perangkat</string>
<string name="discover">Jelajahi</string>
<string name="seerr_integration">Integrasi Seerr</string>
<string name="remove_seerr_server">Hapus Server Seerr</string>
<string name="seerr_server_added">Server Seerr ditambahkan</string>
<string name="password">Kata sandi</string>
<string name="username">Nama pengguna</string>
<string name="url">URL</string>
<string name="trending">Tren</string>
<string name="upcoming_movies">Film Mendatang</string>
<string name="upcoming_tv">Acara TV Mendatang</string>
<string name="request_4k">Minta dalam 4K</string>
<string name="request">Minta</string>
<string name="pending">Tertunda</string>
<string name="software_decoding_av1">Dekode perangkat lunak AV1</string>
</resources> </resources>

View file

@ -375,4 +375,51 @@
<string name="no_servers_found">Nessun server trovato</string> <string name="no_servers_found">Nessun server trovato</string>
<string name="backdrop_display">Stile backdrop</string> <string name="backdrop_display">Stile backdrop</string>
<string name="ends_at">Termina alle %1$s</string> <string name="ends_at">Termina alle %1$s</string>
<string name="resolution_switching">Cambio risoluzione</string>
<string name="local">Locale</string>
<string name="play_trailer">Riproduci trailer</string>
<string name="no_trailers">Nessun trailer</string>
<string name="clear_track_choices">Cancella scelte tracce</string>
<string name="only_forced_subtitles">Solo sottotitoli forzati</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
<string name="dts">DTS</string>
<string name="force_dovi_profile_7">Riproduzione diretta Dolby Vision Profilo 7</string>
<string name="force_dovi_profile_7_summary">Ignora le verifiche di compatibilità del dispositivo</string>
<string name="discover">Scopri</string>
<string name="request">Richiedi</string>
<string name="pending">In attesa</string>
<string name="seerr_integration">Integrazione Seerr</string>
<string name="remove_seerr_server">Rimuovi server Seerr</string>
<string name="password">Password</string>
<string name="username">Nome utente</string>
<string name="url">URL</string>
<string name="trending">In tendenza</string>
<string name="upcoming_movies">Film in arrivo</string>
<string name="upcoming_tv">Serie TV in arrivo</string>
<string name="request_4k">Richiedi in 4K</string>
<string name="seerr_server_added">Server Seerr aggiunto</string>
<string name="voice_search">Ricerca vocale</string>
<string name="voice_starting">Avvio in corso</string>
<string name="voice_search_prompt">Parla per cercare</string>
<string name="processing">Elaborazione in corso</string>
<string name="press_back_to_cancel">Premi indietro per annullare</string>
<string name="voice_error_audio">Errore registrazione audio</string>
<string name="voice_error_client">Errore riconoscimento vocale</string>
<string name="voice_error_permissions">Autorizzazione microfono richiesta</string>
<string name="voice_error_network">Errore di rete</string>
<string name="voice_error_network_timeout">Timeout di rete</string>
<string name="voice_error_no_match">Nessun discorso riconosciuto</string>
<string name="voice_error_busy">Riconoscimento vocale occupato</string>
<string name="voice_error_server">Errore server</string>
<string name="voice_error_speech_timeout">Nessun discorso rilevato</string>
<string name="voice_error_unknown">Errore sconosciuto</string>
<string name="voice_error_start_failed">Impossibile avviare il riconoscimento vocale</string>
<string name="retry">Riprova</string>
<string name="voice_error_timeout">Riconoscimento vocale scaduto</string>
<string name="software_decoding_av1">Decodifica software AV1</string>
<string name="upgrade_mpv_toast">MPV è ora il lettore predefinito, eccetto per i contenuti HDR.\nPuoi modificarlo nelle impostazioni.</string>
</resources> </resources>

View file

@ -1,4 +1,367 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="about">Om</string> <string name="about">Om</string>
<string name="active_recordings">Aktive opptak</string>
<string name="add_favorite">Legg til favoritt</string>
<string name="add_server">Legg til server</string>
<string name="add_user">Legg til bruker</string>
<string name="audio">Lyd</string>
<string name="birthplace">Fødested</string>
<string name="bitrate">Bitrate</string>
<string name="born">Født</string>
<string name="cancel_recording">Avbryt opptak</string>
<string name="cancel_series_recording">Avbryt serieopptak</string>
<string name="cancel">Avbryt</string>
<string name="chapters">Kapitler</string>
<string name="choose_stream">Velg %1$s</string>
<string name="clear_image_cache">Tøm bildebuffer</string>
<string name="collection">Samling</string>
<string name="collections">Samlinger</string>
<string name="commercial">Reklame</string>
<string name="community_rating">Brukervurdering</string>
<string name="compose_error_message_title">En feil har oppstått! Trykk på knappen for å sende logger til serveren din.</string>
<string name="confirm">Bekreft</string>
<string name="continue_watching">Fortsett å se</string>
<string name="critic_rating">Kritikervurdering</string>
<string name="decimal_seconds">%.1f sekunder</string>
<string name="director">Regissør</string>
<string name="disabled">Deaktivert</string>
<string name="discovered_servers">Oppdagede servere</string>
<string name="download_and_update"><![CDATA[Last ned og oppdater]]></string>
<string name="downloading">Laster ned…</string>
<string name="enabled">Aktivert</string>
<string name="enter_server_url">Oppgi server-IP eller URL</string>
<string name="enter_server_address">Oppgi serveradresse</string>
<string name="episodes">Episoder</string>
<string name="error_loading_collection">Feil ved lasting av samling %1$s</string>
<string name="external_track">Ekstern</string>
<string name="favorites">Favoritter</string>
<string name="file_size">Størrelse</string>
<string name="forced_track">Tvungen</string>
<string name="genres">Sjangere</string>
<string name="go_to_series">Gå til serie</string>
<string name="go_to">Gå til</string>
<string name="hide_controller_timeout">Skjul avspillingskontroller</string>
<string name="hide_debug_info">Skjul feilsøkingsinfo</string>
<string name="hide">Skjul</string>
<string name="home">Hjem</string>
<string name="immediate">Umiddelbart</string>
<string name="intro">Intro</string>
<string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ</string>
<string name="library">Bibliotek</string>
<string name="license_info">Lisensinformasjon</string>
<string name="live_tv">Direkte-TV</string>
<string name="loading">Laster…</string>
<string name="mark_entire_series_as_played">Marker hele serien som sett?</string>
<string name="mark_entire_series_as_unplayed">Marker hele serien som usett?</string>
<string name="mark_unwatched">Marker som usett</string>
<string name="mark_watched">Marker som sett</string>
<string name="max_bitrate">Maks bitrate</string>
<string name="more_like_this">Mer som dette</string>
<string name="more">Mer</string>
<string name="movies">Filmer</string>
<string name="name">Navn</string>
<string name="next_up">Neste</string>
<string name="no_data">Ingen data</string>
<string name="no_results">Ingen resultater</string>
<string name="no_servers_found">Ingen servere funnet</string>
<string name="no_scheduled_recordings">Ingen planlagte opptak</string>
<string name="no_update_available">Ingen oppdatering tilgjengelig</string>
<string name="none">Ingen</string>
<string name="outro">Outro</string>
<string name="path">Filbane</string>
<string name="people">Personer</string>
<string name="play_count">Antall avspillinger</string>
<string name="play_from_here">Spill av herfra</string>
<string name="play">Spill av</string>
<string name="playback_debug_info">Vis feilsøkingsinfo for avspilling</string>
<string name="playback_speed">Avspillingshastighet</string>
<string name="playback">Avspilling</string>
<string name="playlist">Spilleliste</string>
<string name="playlists">Spillelister</string>
<string name="preview">Forhåndsvisning</string>
<string name="profile_specific_settings">Innstillinger for brukerprofil</string>
<string name="queue"></string>
<string name="recap">Sammendrag</string>
<string name="recently_added_in">Sist lagt til i %1$s</string>
<string name="recently_added">Sist lagt til</string>
<string name="recently_recorded">Sist tatt opp</string>
<string name="recently_released">Sist utgitt</string>
<string name="recommended">Anbefalt</string>
<string name="record_program">Ta opp program</string>
<string name="record_series">Ta opp serie</string>
<string name="remove_favorite">Fjern favoritt</string>
<string name="restart">Start på nytt</string>
<string name="resume">Fortsett</string>
<string name="save">Lagre</string>
<string name="search_and_download"><![CDATA[Søk og last ned]]></string>
<string name="search">Søk</string>
<string name="searching">Søker…</string>
<string name="select_server">Velg server</string>
<string name="select_user">Velg bruker</string>
<string name="show_debug_info">Vis feilsøkingsinfo</string>
<string name="show_next_up_when">Vis \'Neste\'</string>
<string name="show">Serie</string>
<string name="shuffle">Tilfeldig rekkefølge</string>
<string name="skip">Hopp over</string>
<string name="sort_by_date_added">Dato lagt til</string>
<string name="sort_by_date_episode_added">Dato episode lagt til</string>
<string name="sort_by_date_played">Dato avspilt</string>
<string name="sort_by_date_released">Utgivelsesdato</string>
<string name="sort_by_name">Navn</string>
<string name="sort_by_random">Tilfeldig</string>
<string name="studios">Studioer</string>
<string name="submit">Send inn</string>
<string name="subtitle_download_too_long">Nedlastingen tar lang tid, du må kanskje starte avspillingen på nytt</string>
<string name="subtitle">Undertekst</string>
<string name="subtitles">Undertekster</string>
<string name="suggestions">Forslag</string>
<string name="switch_servers">Bytt server</string>
<string name="switch_user">Bytt bruker</string>
<string name="top_unwatched">Best vurderte usette</string>
<string name="trailer">Trailer</string>
<plurals name="trailers">
<item quantity="one">Trailere</item>
<item quantity="other"></item>
</plurals>
<string name="tv_dvr_schedule">DVR-plan</string>
<string name="tv_guide">Programoversikt</string>
<string name="tv_season">Sesong</string>
<string name="tv_seasons">Sesonger</string>
<string name="tv_shows">TV-serier</string>
<string name="ui_interface">Grensesnitt</string>
<string name="unknown">Ukjent</string>
<string name="updates">Oppdateringer</string>
<string name="version">Versjon</string>
<string name="video_scale">Videoskalering</string>
<string name="video">Video</string>
<string name="videos">Videoer</string>
<string name="watch_live">Se direkte</string>
<string name="years_old">%1$d år gammel</string>
<string name="play_with_transcoding">Spill av med transkoding</string>
<string name="media_information">Mediainformasjon</string>
<string name="show_clock">Vis klokke</string>
<string name="aired_episode_order">Rekkefølge etter sendedato</string>
<string name="create_playlist">Lag ny spilleliste</string>
<string name="add_to_playlist">Legg til i spilleliste</string>
<string name="one_click_pause">Pause med ett klikk</string>
<string name="one_click_pause_summary_on">Trykk i midten på styreputen for å pause/spille av</string>
<string name="italic_font">Kursiv skrift</string>
<string name="font">Skrifttype</string>
<string name="background">Bakgrunn</string>
<string name="success">Fullført</string>
<string name="official_rating">Aldersgrense</string>
<string name="runtime_sort">Spilletid</string>
<string name="extras">Ekstramateriale</string>
<plurals name="other_extras">
<item quantity="one">Annet</item>
<item quantity="other"></item>
</plurals>
<plurals name="behind_the_scenes">
<item quantity="one">Bak kulissene</item>
<item quantity="other"></item>
</plurals>
<plurals name="theme_songs">
<item quantity="one">Temasanger</item>
<item quantity="other"></item>
</plurals>
<plurals name="theme_videos">
<item quantity="one">Temavideoer</item>
<item quantity="other"></item>
</plurals>
<plurals name="clips">
<item quantity="one">Klipp</item>
<item quantity="other"></item>
</plurals>
<plurals name="deleted_scenes">
<item quantity="one">Slettede scener</item>
<item quantity="other"></item>
</plurals>
<plurals name="interviews">
<item quantity="one">Intervjuer</item>
<item quantity="other"></item>
</plurals>
<plurals name="scenes">
<item quantity="one">Scener</item>
<item quantity="other"></item>
</plurals>
<plurals name="samples">
<item quantity="one">Smakebiter</item>
<item quantity="other"></item>
</plurals>
<plurals name="featurettes">
<item quantity="one">Bakomfilmer</item>
<item quantity="other"></item>
</plurals>
<plurals name="shorts">
<item quantity="one">Kortfilmer</item>
<item quantity="other"></item>
</plurals>
<plurals name="downloads">
<item quantity="one">%s nedlasting</item>
<item quantity="other">%s nedlastinger</item>
</plurals>
<plurals name="hours">
<item quantity="one">%d time</item>
<item quantity="other">%d timer</item>
</plurals>
<plurals name="items">
<item quantity="one">%d element</item>
<item quantity="other">%d elementer</item>
</plurals>
<plurals name="seconds">
<item quantity="one">%d sekund</item>
<item quantity="other">%d sekunder</item>
</plurals>
<string name="advanced_settings">Avanserte innstillinger</string>
<string name="advanced_ui">Avansert grensesnitt</string>
<string name="app_theme">App-tema</string>
<string name="auto_check_for_updates">Sjekk automatisk etter oppdateringer</string>
<string name="auto_play_next_delay">Forsinkelse før neste spilles av</string>
<string name="auto_play_next">Spill neste automatisk</string>
<string name="check_for_updates">Sjekk etter oppdateringer</string>
<string name="combine_continue_next_summary">Gjelder kun TV-serier</string>
<string name="combine_continue_next"><![CDATA[Slå sammen \'Fortsett å se\' og \'Neste\']]></string>
<string name="direct_play_ass">Direkteavspilling av ASS-undertekster</string>
<string name="direct_play_pgs">Direkteavspilling av PGS-undertekster</string>
<string name="downmix_stereo">Alltid nedmiks til stereo</string>
<string name="ffmpeg_extension_pref">Bruk FFmpeg-dekodermodul</string>
<string name="global_content_scale">Standard innholdsskalering</string>
<string name="install_update">Installer oppdatering</string>
<string name="installed_version">Installert versjon</string>
<string name="max_homepage_items">Maks elementer på rader på hjemskjermen</string>
<string name="nav_drawer_pins_summary">Velg standardelementer som skal vises; andre skjules</string>
<string name="nav_drawer_pins">Tilpass elementer i navigasjonsskuffen</string>
<string name="nav_drawer_switch_on_focus_summary_off">Klikk for å bytte side</string>
<string name="nav_drawer_switch_on_focus">Bytt side i navigasjonsskuffen ved fokus</string>
<string name="pass_out_protection">Søvnbeskyttelse</string>
<string name="play_theme_music">Spill temamusikk</string>
<string name="playback_overrides">Overstyring av avspilling</string>
<string name="remember_selected_tab">Husk valgte faner</string>
<string name="rewatch_next_up">Tillat gjensyn i \'Neste\'</string>
<string name="seek_bar_steps">Steglengde for søkelinje</string>
<string name="send_app_logs_summary">Nyttig for feilsøking</string>
<string name="send_app_logs">Send app-logger til gjeldende server</string>
<string name="send_crash_reports_summary">Vil prøve å sende til sist tilkoblede server</string>
<string name="send_crash_reports">Send krasjrapporter</string>
<string name="settings">Innstillinger</string>
<string name="skip_back_on_resume_preference">Hopp tilbake ved gjenopptakelse av avspilling</string>
<string name="skip_back_preference">Hopp tilbake</string>
<string name="skip_commercials_behavior">Handling for hopping over reklame</string>
<string name="skip_forward_preference">Hopp fremover</string>
<string name="skip_intro_behavior">Handling for hopping over intro</string>
<string name="skip_outro_behavior">Handling for hopping over outro</string>
<string name="skip_previews_behavior">Handling for hopping over forhåndsvisning</string>
<string name="skip_recap_behavior">Handling for hopping over sammendrag</string>
<string name="update_available">Oppdatering tilgjengelig</string>
<string name="update_url_summary">URL for sjekk av app-oppdateringer</string>
<string name="update_url">URL for oppdatering</string>
<string name="font_size">Skriftstørrelse</string>
<string name="font_color">Skriftfarge</string>
<string name="font_opacity">Gjennomsiktighet for skrift</string>
<string name="edge_style">Kantstil</string>
<string name="edge_color">Kantfarge</string>
<string name="background_opacity">Gjennomsiktighet for bakgrunn</string>
<string name="background_style">Bakgrunnsstil</string>
<string name="subtitle_style">Stil for undertekst</string>
<string name="background_color">Bakgrunnsfarge</string>
<string name="reset">Nullstill</string>
<string name="bold_font">Fet skrift</string>
<string name="player_backend">Avspillingsmotor</string>
<string name="mpv_hardware_decoding">MPV: Bruk maskinvaredekoding</string>
<string name="disable_if_crash">Deaktiver dersom appen krasjer</string>
<string name="mpv_options">MPV-valg</string>
<string name="exoplayer_options">ExoPlayer-valg</string>
<string name="skip_segment_unknown">Hopp over segment</string>
<string name="skip_segment_commercial">Hopp over reklame</string>
<string name="skip_segment_preview">Hopp over forhåndsvisning</string>
<string name="skip_segment_recap">Hopp over sammendrag</string>
<string name="skip_segment_outro">Hopp over outro</string>
<string name="skip_segment_intro">Hopp over intro</string>
<string name="played">Sett</string>
<string name="filter">Filter</string>
<string name="year">År</string>
<string name="decade">Tiår</string>
<string name="remove">Fjern</string>
<string name="dolby_vision">Dolby Vision</string>
<string name="dolby_atmos">Dolby Atmos</string>
<string name="mpv_use_gpu_next">MPV: Bruk gpu-next</string>
<string name="mpv_conf">Rediger mpv.conf</string>
<string name="discard_change">Forkast endringer?</string>
<string name="subtitle_margin">Margin</string>
<string name="verbose_logging">Detaljert logging</string>
<string name="enter_pin">Tast inn PIN</string>
<string name="sign_in_auto">Logg inn automatisk</string>
<string name="use_server_credentials">Logg inn via server</string>
<string name="press_enter_to_confirm">Trykk i midten for å bekrefte</string>
<string name="require_pin_code">Krev PIN for profil</string>
<string name="confirm_pin">Bekreft PIN</string>
<string name="incorrect">Feil</string>
<string name="pin_too_short">PIN må være 4 taster eller lenger</string>
<string name="will_remove_pin">Vil fjerne PIN</string>
<string name="image_cache_size">Størrelse på bildebuffer (MB)</string>
<string name="view_options">Visningsvalg</string>
<string name="columns">Kolonner</string>
<string name="spacing">Mellomrom</string>
<string name="aspect_ratio">Bildeforhold</string>
<string name="show_details">Vis detaljer</string>
<string name="image_type">Bildetype</string>
<string name="cast_and_crew"><![CDATA[Skuespillere og medarbeidere]]></string>
<string name="guest_stars">Gjesteroller</string>
<string name="edge_size">Kantstørrelse</string>
<string name="refresh_rate_switching">Bytting av bildeoppdateringsfrekvens</string>
<string name="automatic">Automatisk</string>
<string name="username_or_password">Bruk brukernavn/passord</string>
<string name="login">Logg inn</string>
<string name="general">Generelt</string>
<string name="container">Container</string>
<string name="title">Tittel</string>
<string name="codec">Kodek</string>
<string name="profile">Profil</string>
<string name="level">Nivå</string>
<string name="resolution">Oppløsning</string>
<string name="anamorphic">Anamorfisk</string>
<string name="interlaced">Linjeflettet</string>
<string name="framerate">Bildefrekvens</string>
<string name="bit_depth">Bit-dybde</string>
<string name="video_range">Videoområde</string>
<string name="video_range_type">Type videoområde</string>
<string name="color_space">Fargerom</string>
<string name="color_transfer">Fargeoverføring</string>
<string name="color_primaries">Fargeprimærer</string>
<string name="pixel_format">Pikselformat</string>
<string name="ref_frames">Referanserammer</string>
<string name="nal">NAL</string>
<string name="language">Språk</string>
<string name="layout">Layout</string>
<string name="channels">Kanaler</string>
<string name="sample_rate">Samplingsfrekvens</string>
<string name="avc">AVC</string>
<string name="yes">Ja</string>
<string name="no">Nei</string>
<string name="sdr">SDR</string>
<string name="hdr">HDR</string>
<string name="hdr10">HDR10</string>
<string name="hdr10_plus">HDR10+</string>
<string name="hlg">HLG</string>
<string name="bit_unit">bit</string>
<string name="sample_rate_unit">Hz</string>
<string name="show_titles">Vis titler</string>
<string name="live_tv_repeat">Repeter</string>
<string name="favorite_channels_at_beginning">Vis favorittkanaler først</string>
<string name="sort_channels_recently_watched">Sorter kanaler etter sist sett</string>
<string name="color_code_programs">Fargekod programmer</string>
<string name="subtitle_delay">Undertekstforsinkelse</string>
<string name="backdrop_display">Bakgrunnsstil</string>
<string name="default_track">Standard</string>
<string name="delete">Slette</string>
<string name="died">Død</string>
<string name="directed_by">Regissert av %1$s</string>
<string name="ends_at">Slutter kl. %1$s</string>
<string name="ac3_supported">Enheten støtter AC3/Dolby Digital</string>
<string name="resolution_switching">Bytting av oppløsning</string>
<string name="local">Lokal</string>
<string name="play_trailer">Spill trailer</string>
<string name="no_trailers">Ingen trailere</string>
<string name="clear_track_choices">Fjern valg av spor</string>
</resources> </resources>

View file

@ -375,4 +375,51 @@
<string name="no_servers_found">Nenhum servidor encontrado</string> <string name="no_servers_found">Nenhum servidor encontrado</string>
<string name="backdrop_display">Estilo de fundo</string> <string name="backdrop_display">Estilo de fundo</string>
<string name="ends_at">Termina às %1$s</string> <string name="ends_at">Termina às %1$s</string>
<string name="resolution_switching">Mudança de resolução</string>
<string name="local">Local</string>
<string name="play_trailer">Reproduzir trailer</string>
<string name="no_trailers">Sem trailers</string>
<string name="clear_track_choices">Apagar seleções de faixas</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
<string name="dts">DTS</string>
<string name="only_forced_subtitles">Apenas Legendas Forçadas</string>
<string name="force_dovi_profile_7">Reprodução direta Dolby Vision Profile 7</string>
<string name="force_dovi_profile_7_summary">Ignora as verificações de compatibilidade do dispositivo</string>
<string name="discover">Descobrir</string>
<string name="request">Pedir</string>
<string name="pending">Pendente</string>
<string name="seerr_integration">Integração com Seerr</string>
<string name="remove_seerr_server">Remover Servidor Seerr</string>
<string name="seerr_server_added">Servidor Seerr adicionado</string>
<string name="url">URL</string>
<string name="trending">Popular</string>
<string name="upcoming_movies">Filmes em Breve</string>
<string name="upcoming_tv">Séries em Breve</string>
<string name="request_4k">Pedir em 4K</string>
<string name="password">Palavra-passe</string>
<string name="username">Utilizador</string>
<string name="voice_search">Pesquisa por voz</string>
<string name="voice_starting">A Iniciar</string>
<string name="voice_search_prompt">Fale para pesquisar</string>
<string name="processing">A Processar</string>
<string name="press_back_to_cancel">Pressione recuar para cancelar</string>
<string name="voice_error_audio">Erro na gravação de áudio</string>
<string name="voice_error_client">Erro no reconhecimento de voz</string>
<string name="voice_error_permissions">É necessária permissão para o microfone</string>
<string name="voice_error_network">Erro de rede</string>
<string name="voice_error_network_timeout">Tempo limite da rede</string>
<string name="voice_error_no_match">Nenhuma fala reconhecida</string>
<string name="voice_error_busy">Reconhecimento de voz ocupado</string>
<string name="voice_error_server">Erro do servidor</string>
<string name="voice_error_speech_timeout">Nenhuma fala detetada</string>
<string name="voice_error_unknown">Erro desconhecido</string>
<string name="voice_error_start_failed">Falha ao iniciar o reconhecimento de voz</string>
<string name="voice_error_timeout">Tempo limite do reconhecimento de voz expirado</string>
<string name="retry">Tentar novamente</string>
<string name="software_decoding_av1">Decodificação de AV1 por software</string>
<string name="upgrade_mpv_toast">O MPV é agora o reprodutor padrão, exceto para HDR.\nPode alterar esta configuração nas definições.</string>
</resources> </resources>

View file

@ -299,15 +299,15 @@
</plurals> </plurals>
<plurals name="samples"> <plurals name="samples">
<item quantity="one">Отрывки</item> <item quantity="one">Отрывки</item>
<item quantity="few"></item> <item quantity="few"/>
<item quantity="many"></item> <item quantity="many"/>
<item quantity="other"></item> <item quantity="other"/>
</plurals> </plurals>
<plurals name="featurettes"> <plurals name="featurettes">
<item quantity="one">Дополнительные материалы</item> <item quantity="one">Дополнительные материалы</item>
<item quantity="few"></item> <item quantity="few"/>
<item quantity="many"></item> <item quantity="many"/>
<item quantity="other"></item> <item quantity="other"/>
</plurals> </plurals>
<string name="player_backend">Выбор плеера</string> <string name="player_backend">Выбор плеера</string>
<string name="edge_size">Толщина обводки</string> <string name="edge_size">Толщина обводки</string>
@ -316,15 +316,15 @@
<string name="forced_track">Форсированные</string> <string name="forced_track">Форсированные</string>
<plurals name="theme_songs"> <plurals name="theme_songs">
<item quantity="one">Саундтреки</item> <item quantity="one">Саундтреки</item>
<item quantity="few"></item> <item quantity="few"/>
<item quantity="many"></item> <item quantity="many"/>
<item quantity="other"></item> <item quantity="other"/>
</plurals> </plurals>
<plurals name="theme_videos"> <plurals name="theme_videos">
<item quantity="one">Видеоролики</item> <item quantity="one">Видеоролики</item>
<item quantity="few"></item> <item quantity="few"/>
<item quantity="many"></item> <item quantity="many"/>
<item quantity="other"></item> <item quantity="other"/>
</plurals> </plurals>
<string name="nav_drawer_pins">Настроить элементы боковой панели</string> <string name="nav_drawer_pins">Настроить элементы боковой панели</string>
<string name="nav_drawer_pins_summary">Выберите элементы для показа на панели, остальные элементы будут скрыты</string> <string name="nav_drawer_pins_summary">Выберите элементы для показа на панели, остальные элементы будут скрыты</string>
@ -339,8 +339,58 @@
<string name="pass_out_protection">Остановка воспроизведения при бездействии</string> <string name="pass_out_protection">Остановка воспроизведения при бездействии</string>
<plurals name="shorts"> <plurals name="shorts">
<item quantity="one">Короткометражки</item> <item quantity="one">Короткометражки</item>
<item quantity="few"></item> <item quantity="few"/>
<item quantity="many"></item> <item quantity="many"/>
<item quantity="other"></item> <item quantity="other"/>
</plurals> </plurals>
<string name="ends_at">Конец в %1$s</string>
<string name="enter_server_address">Введите адрес сервера</string>
<string name="no_servers_found">Серверы не найдены</string>
<string name="only_forced_subtitles">Только форсированные субтитры</string>
<string name="voice_search">Голосовой поиск</string>
<string name="voice_starting">Запуск</string>
<string name="voice_search_prompt">Говорите для поиска</string>
<string name="processing">Обработка</string>
<string name="press_back_to_cancel">Нажмите назад для отмены</string>
<string name="voice_error_audio">Ошибка записи звука</string>
<string name="voice_error_client">Ошибка распознавания голоса</string>
<string name="voice_error_permissions">Требуется разрешение на использование микрофона</string>
<string name="voice_error_network">Сетевая ошибка</string>
<string name="voice_error_network_timeout">Сетевой таймаут</string>
<string name="voice_error_no_match">Речь не распознана</string>
<string name="voice_error_busy">Голосовое распознавание занято</string>
<string name="voice_error_server">Ошибка сервера</string>
<string name="voice_error_speech_timeout">Речь не обнаружена</string>
<string name="voice_error_unknown">Неизвестная ошибка</string>
<string name="voice_error_start_failed">Не удалось запустить распознавание голоса</string>
<string name="voice_error_timeout">Время ожидания распознавания голоса истекло</string>
<string name="retry">Повторить</string>
<string name="media_information">О медиаданных</string>
<string name="refresh_rate_switching">Переключение частоты обновления</string>
<string name="automatic">Автоматически</string>
<string name="username_or_password">Исп. имя пользователя/пароль</string>
<string name="login">Логин</string>
<string name="password">Пароль</string>
<string name="username">Имя пользователя</string>
<string name="language">Язык</string>
<string name="general">Основное</string>
<string name="container">Контейнер</string>
<string name="title">Заголовок</string>
<string name="codec">Кодек</string>
<string name="profile">Профиль</string>
<string name="level">Уровень</string>
<string name="resolution">Разрешение</string>
<string name="anamorphic">Анаморфность</string>
<string name="interlaced">Чересстрочность</string>
<string name="framerate">Ч-та кадров</string>
<string name="bit_depth">Разрядность</string>
<string name="video_range">Диапазон видео</string>
<string name="video_range_type">Тип диапазона видео</string>
<string name="color_space">Цветовое пространство</string>
<string name="color_transfer">Передача цвета</string>
<string name="color_primaries">Основные цвета</string>
<string name="pixel_format">Формат пикселей</string>
<string name="ref_frames">Опорные кадры</string>
<string name="yes">Да</string>
<string name="no">Нет</string>
</resources> </resources>

View file

@ -0,0 +1,456 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="about">Про застосунок</string>
<string name="active_recordings">Активні Записи</string>
<string name="add_favorite">Улюблене</string>
<string name="add_server">Додати сервер</string>
<string name="add_user">Додати користувача</string>
<string name="audio">Аудіо</string>
<string name="birthplace">Місце народження</string>
<string name="bitrate">Бітрейт</string>
<string name="born">Місце народженя</string>
<string name="cancel_recording">Скасувати запис</string>
<string name="cancel_series_recording">Скасувати запис серіалу</string>
<string name="cancel">Скасувати</string>
<string name="chapters">Розділи</string>
<string name="choose_stream">Виберіть%1$s</string>
<string name="clear_image_cache">Очистити кеш зображень</string>
<string name="collection">Колекція</string>
<string name="collections">Колекції</string>
<string name="commercial">Реклама</string>
<string name="community_rating">Оцінка спільноти</string>
<string name="compose_error_message_title">Сталася помилка! Натисніть кнопку, щоб надіслати звіт на ваш сервер.</string>
<string name="confirm">Підтвердити</string>
<string name="continue_watching">Продовжити перегляд</string>
<string name="critic_rating">Оцінка критиків</string>
<string name="decimal_seconds">%.1fсекунд</string>
<string name="default_track">За замовчуванням</string>
<string name="delete">Видалити</string>
<string name="died">Помер</string>
<string name="directed_by">Режисер: %1$s</string>
<string name="director">Режисер</string>
<string name="disabled">Вимкнутий</string>
<string name="discovered_servers">Виявлені сервери</string>
<string name="download_and_update"><![CDATA[Download & Update]]></string>
<string name="downloading">Завантаження…</string>
<string name="enabled">Увімкнено</string>
<string name="ends_at">Закінчується о %1$s</string>
<string name="enter_server_url">Введіть IP-адресу сервера або URL-адресу</string>
<string name="enter_server_address">Введіть адресу сервера</string>
<string name="episodes">Епізоди</string>
<string name="error_loading_collection">Помилка під час завантаження колекції %1$s</string>
<string name="external_track">Зовнішній</string>
<string name="favorites">Улюблені</string>
<string name="file_size">Розмір</string>
<string name="forced_track">Forced</string>
<string name="genres">Жанри</string>
<string name="go_to_series">Перейти до серії</string>
<string name="go_to">Перейти до</string>
<string name="hide_controller_timeout">Приховати елементи керування відтворенням</string>
<string name="hide_debug_info">Приховати інформацію про налагодження</string>
<string name="hide">Приховати</string>
<string name="home">Головна</string>
<string name="immediate">Негайно</string>
<string name="intro">Вступ</string>
<string name="jump_letters">#абвгґдеєжзиіїйклмнопрстуфхцчшщьюя</string>
<string name="library">Бібліотека</string>
<string name="license_info">Інформація про ліцензію</string>
<string name="live_tv">Ефірне ТБ</string>
<string name="loading">Завантаження…</string>
<string name="mark_entire_series_as_played">Серія переглянута?</string>
<string name="mark_entire_series_as_unplayed">Позначити серію не відтворену?</string>
<string name="mark_unwatched">Позначити як непереглянуте</string>
<string name="mark_watched">Позначити як переглянуте</string>
<string name="max_bitrate">Максимальна швидкість передачі даних</string>
<string name="more_like_this">Більше подібного</string>
<string name="more">Більше</string>
<plurals name="movies">
<item quantity="one">Фільми</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<string name="name">Ім\'я</string>
<string name="next_up">Наступний</string>
<string name="no_data">Дані відсутні</string>
<string name="no_results">Відсутні результати</string>
<string name="no_servers_found">Сервери не знайдені</string>
<string name="no_scheduled_recordings">Заплановані записи відсутні</string>
<string name="no_update_available">Немає доступних оновлень</string>
<string name="none">Нічого</string>
<string name="only_forced_subtitles">Тільки примусові субтитри</string>
<string name="outro">Завершення</string>
<string name="path">Шлях</string>
<plurals name="people">
<item quantity="one">Люди</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<string name="play_count">Кількість відтворень</string>
<string name="play_from_here">Відтворити звідси</string>
<string name="play">Відтворити</string>
<string name="playback_debug_info">Показати інформацію про налагодження відтворення</string>
<string name="playback_speed">Швидкість відтворення</string>
<string name="playback">Відтворення</string>
<string name="playlist">Список відтворення</string>
<string name="playlists">Списки відтворення</string>
<string name="preview">Попередній перегляд</string>
<string name="profile_specific_settings">Налаштування користувача</string>
<string name="queue">Черга</string>
<string name="recap">Підсумок</string>
<string name="recently_added_in">Нещодавно додано: %1$s</string>
<string name="recently_added">Нещодавно додано</string>
<string name="recently_recorded">Нещодавно записано</string>
<string name="recently_released">Нещодавно випущено</string>
<string name="recommended">Рекомендовано</string>
<string name="record_program">Програма запису</string>
<string name="record_series">Запис серії</string>
<string name="remove_favorite">Не улюблений</string>
<string name="restart">Перезавантаження</string>
<string name="resume">Відновити</string>
<string name="save">Зберегти</string>
<string name="search_and_download"><![CDATA[Search & Download]]></string>
<string name="search">Пошук</string>
<string name="searching">Шукаю…</string>
<string name="voice_search">Голосовий пошук</string>
<string name="voice_starting">Початок</string>
<string name="voice_search_prompt">Говорити для пошуку</string>
<string name="processing">Обробка</string>
<string name="press_back_to_cancel">Натисніть назад, щоб скасувати</string>
<string name="voice_error_audio">Помилка аудіозапису</string>
<string name="voice_error_client">Помилка розпізнавання голосу</string>
<string name="voice_error_permissions">Потрібен дозвіл на використання мікрофона</string>
<string name="voice_error_network">Помилка мережі</string>
<string name="voice_error_network_timeout">Час очікування мережі завершився</string>
<string name="voice_error_no_match">Не розпізнано мовлення</string>
<string name="voice_error_busy">Розпізнавання голосу зайнято</string>
<string name="voice_error_server">Помилка сервера</string>
<string name="voice_error_speech_timeout">Не виявлено мовлення</string>
<string name="voice_error_unknown">Невідома помилка</string>
<string name="voice_error_start_failed">Не вдалося запустити розпізнавання голосу</string>
<string name="voice_error_timeout">Час очікування розпізнавання голосу закінчився</string>
<string name="retry">Повторити спробу</string>
<string name="select_server">Обрати сервер</string>
<string name="select_user">Обрати користувача</string>
<string name="show_debug_info">Показати додаткову інформацію</string>
<string name="show_next_up_when">Показати наступне</string>
<string name="show">Показати</string>
<string name="shuffle">Перемішати</string>
<string name="skip">Пропустити</string>
<string name="sort_by_date_added">Дата додавання</string>
<string name="sort_by_date_episode_added">Дата додавання епізоду</string>
<string name="sort_by_date_played">Дата відтворення</string>
<string name="sort_by_date_released">Дата випуску</string>
<string name="sort_by_name">Ім\'я</string>
<string name="sort_by_random">Випадковий</string>
<string name="studios">Судії</string>
<string name="submit">Надіслати</string>
<string name="subtitle_download_too_long">Завантаження триває надто довго, можливо, потрібно перезапустити відтворення</string>
<string name="subtitle">Підзаголовок</string>
<string name="subtitles">Субтитри</string>
<string name="suggestions">Пропозиції</string>
<string name="switch_servers">Перемикнути сервер</string>
<string name="switch_user">Перемкнути</string>
<string name="top_unwatched">Топ непереглянутих</string>
<string name="trailer">Трейлер</string>
<plurals name="trailers">
<item quantity="one">Трейлери</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<string name="tv_dvr_schedule">Розклад DVR</string>
<string name="tv_guide">Посібник</string>
<string name="tv_season">Сезон</string>
<string name="tv_seasons">Сезони</string>
<plurals name="tv_shows">
<item quantity="one">Серіали</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<string name="ui_interface">Інтерфейс</string>
<string name="unknown">Невідомо</string>
<string name="updates">Оновлення</string>
<string name="version">Версія</string>
<string name="video_scale">Маштаб відео</string>
<string name="video">Відео</string>
<string name="videos">Відоси</string>
<string name="watch_live">Дивитись онлайн</string>
<string name="years_old">%1$d років</string>
<string name="play_with_transcoding">Відтворити з перекодуванням</string>
<string name="media_information">Інформація</string>
<string name="show_clock">Показати годиник</string>
<string name="aired_episode_order">Порядок ефірних епізодів</string>
<string name="create_playlist">Створити новий список відтворення</string>
<string name="add_to_playlist">Додати до списку відтворення</string>
<string name="one_click_pause">Пауза в один клік</string>
<string name="one_click_pause_summary_on">Натисніть центр D-Pad, щоб призупинити/відтворити</string>
<string name="italic_font">Курсив</string>
<string name="font">Шрифт</string>
<string name="background">Обкладенка</string>
<string name="success">Успіх</string>
<string name="official_rating">Вікова оцінка</string>
<string name="runtime_sort">Час виконання</string>
<string name="extras">Додатково</string>
<plurals name="other_extras">
<item quantity="one">Інше</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="behind_the_scenes">
<item quantity="one">Позакадром</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="theme_songs">
<item quantity="one">Тематичні пісні</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="theme_videos">
<item quantity="one">Тематичні відео</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="clips">
<item quantity="one">Кліпи</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="deleted_scenes">
<item quantity="one">Видалені сцени</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="interviews">
<item quantity="one">Інтерв\'ю</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="scenes">
<item quantity="one">Сцени</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="samples">
<item quantity="one">Зразки</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="featurettes">
<item quantity="one">Короткометражні фільми</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="shorts">
<item quantity="one">Шорти</item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="downloads">
<item quantity="one">%s завантаження</item>
<item quantity="few">%s завантаження</item>
<item quantity="many">%s завантажень</item>
<item quantity="other">%s завантажень</item>
</plurals>
<plurals name="hours">
<item quantity="one">%d година</item>
<item quantity="few">%d години</item>
<item quantity="many">%d годин</item>
<item quantity="other">%d годин</item>
</plurals>
<plurals name="items">
<item quantity="one">%d елемент</item>
<item quantity="few">%d елемента</item>
<item quantity="many">%d елементів</item>
<item quantity="other">%d елементів</item>
</plurals>
<plurals name="seconds">
<item quantity="one">%d секунда</item>
<item quantity="few">%d секунди</item>
<item quantity="many">%d секунд</item>
<item quantity="other">%d секунд</item>
</plurals>
<string name="ac3_supported">Пристрій підтримує AC3/Dolby Digital</string>
<string name="advanced_settings">Додаткові налаштування</string>
<string name="advanced_ui">Розширений інтерфейс користувача</string>
<string name="app_theme">Тема застосунку</string>
<string name="auto_check_for_updates">Автоматична перевірка оновлень</string>
<string name="auto_play_next_delay">Затримка перед відтворенням наступного</string>
<string name="auto_play_next">Автоматичне відтворення наступного</string>
<string name="check_for_updates">Перевірити оноалення</string>
<string name="combine_continue_next_summary">Застосовується тільки до серіалів</string>
<string name="direct_play_ass">Пряме відтворення субтитрів ASS</string>
<string name="direct_play_pgs">Пряме відтворення субтитрів PGS</string>
<string name="downmix_stereo">Завжди змішувати в стерео</string>
<string name="ffmpeg_extension_pref">Використовувати модуль декодера FFmpeg</string>
<string name="global_content_scale">Масштабувати контент за замовчуванням</string>
<string name="install_update">Встановити оновлення</string>
<string name="installed_version">Встановлена версія</string>
<string name="max_homepage_items">Максимальна кількість елементів в рядку на головній сторінці</string>
<string name="nav_drawer_pins_summary">Виберіть елементи, які будуть відображатися за замовчуванням, інші будуть приховані</string>
<string name="nav_drawer_pins">Налаштування елементів навігаційного меню</string>
<string name="nav_drawer_switch_on_focus_summary_off">Натисніть, щоб перейти на іншу сторінку</string>
<string name="nav_drawer_switch_on_focus">Перемикання сторінок навігаційного меню при фокусуванні</string>
<string name="pass_out_protection">Захист від відключення</string>
<string name="play_theme_music">Відтворити музичну тему</string>
<string name="playback_overrides">Перегляд записів</string>
<string name="remember_selected_tab">Запам\'ятати вибрані вкладки</string>
<string name="rewatch_next_up">Увімкнути повторний перегляд як наступне</string>
<string name="seek_bar_steps">Кроки пошуку</string>
<string name="send_app_logs_summary">Корисно для налагодження</string>
<string name="send_app_logs">Надіслати журнали програми на поточний сервер</string>
<string name="send_crash_reports_summary">Спробує надіслати на останній підключений сервер</string>
<string name="send_crash_reports">Надіслати звіти про збій</string>
<string name="settings">Налаштування</string>
<string name="skip_back_on_resume_preference">Перейти назад при відновленні відтворення</string>
<string name="skip_back_preference">Перейти назад</string>
<string name="skip_commercials_behavior">Пропускати рекламу</string>
<string name="skip_forward_preference">Перемотати вперед</string>
<string name="skip_intro_behavior">Пропустити початок</string>
<string name="skip_outro_behavior">Пропустити кінець</string>
<string name="skip_previews_behavior">Пропусти попередній перегляд</string>
<string name="skip_recap_behavior">Пропустити підсумок</string>
<string name="update_available">Оновлення доступні</string>
<string name="update_url_summary">URL для перевірки оновлень</string>
<string name="update_url">Оновити URL</string>
<string name="font_size">Розмір шрифта</string>
<string name="font_color">Колір шрифта</string>
<string name="font_opacity">Непрозорість шрифту</string>
<string name="edge_style">Стиль контурів</string>
<string name="edge_color">Колір контуру</string>
<string name="background_opacity">Прозорість фону</string>
<string name="background_style">Стиль фону</string>
<string name="subtitle_style">Стиль заголовку</string>
<string name="background_color">Колір фону</string>
<string name="reset">Скинути</string>
<string name="bold_font">Жирний шрифт</string>
<string name="player_backend">Playback Backend</string>
<string name="mpv_hardware_decoding">MPV: Використовувати апаратне декодування</string>
<string name="disable_if_crash">Вимкніть, якщо ви стикаєтеся з проблемами</string>
<string name="mpv_options">Варіанти MPV</string>
<string name="exoplayer_options">Параметри ExoPlayer</string>
<string name="skip_segment_unknown">Пропустити сегмент</string>
<string name="skip_segment_commercial">Пропустити реклами</string>
<string name="skip_segment_preview">Пропустити попередній перегляд</string>
<string name="skip_segment_recap">Пропустити повтор</string>
<string name="skip_segment_outro">Пропустити кінець</string>
<string name="skip_segment_intro">Пропустити вступ</string>
<string name="played">Відтворено</string>
<string name="filter">Фільтр</string>
<string name="year">Рік</string>
<string name="decade">Десятиліття</string>
<string name="remove">Видалити</string>
<string name="dolby_vision">Dolby Vision</string>
<string name="dolby_atmos">Dolby Atmos</string>
<string name="mpv_use_gpu_next">MPV: Використовувати gpu</string>
<string name="mpv_conf">Редагувати mpv.conf</string>
<string name="discard_change">Відмінити зміни?</string>
<string name="subtitle_margin">Відступ</string>
<string name="verbose_logging">Детальне ведення журналу</string>
<string name="enter_pin">Ведіть PIN</string>
<string name="sign_in_auto">Автоматичний вхід</string>
<string name="use_server_credentials">Увійти через сервер</string>
<string name="press_enter_to_confirm">Натисніть по центру для підтвердження</string>
<string name="require_pin_code">Вимагати PIN для профілю</string>
<string name="confirm_pin">Підтвердити PIN</string>
<string name="incorrect">Невірно</string>
<string name="pin_too_short">PIN-код повинен складатися з 4 або більше символів</string>
<string name="will_remove_pin">Видалити PIN-код</string>
<string name="image_cache_size">Розмір кешу зображень (МБ)</string>
<string name="view_options">Переглянути параметри</string>
<string name="columns">Колонки</string>
<string name="spacing">Відступ(Інтервал)</string>
<string name="aspect_ratio">Співвідношення сторін</string>
<string name="show_details">Показати деталі</string>
<string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string>
<string name="image_type">Тип зображення</string>
<string name="cast_and_crew"><![CDATA[Cast & Crew]]></string>
<string name="guest_stars">Запрошені зірки</string>
<string name="edge_size">Розмір контуру</string>
<string name="refresh_rate_switching">Перемикання частоти оновлення</string>
<string name="automatic">Автоматично</string>
<string name="username_or_password">Використовуйте ім\'я користувача/пароль</string>
<string name="login">Увійти</string>
<string name="general">Загальні</string>
<string name="container">Контейнер</string>
<string name="title">Заголовок</string>
<string name="codec">Codec</string>
<string name="profile">Профіль</string>
<string name="level">Рівень</string>
<string name="resolution">Розширення</string>
<string name="anamorphic">Anamorphic</string>
<string name="interlaced">Interlaced</string>
<string name="framerate">Частота кадрів</string>
<string name="bit_depth">Bit depth</string>
<string name="video_range">Діапазон відео</string>
<string name="video_range_type">Тип діапазону відео</string>
<string name="color_space">Кольоровий простір</string>
<string name="color_transfer">Перенесення кольору</string>
<string name="color_primaries">Головний колір</string>
<string name="pixel_format">Формат пікселю</string>
<string name="ref_frames">Ref frames</string>
<string name="nal">NAL</string>
<string name="language">Мова</string>
<string name="layout">Компонування</string>
<string name="channels">Канали</string>
<string name="sample_rate">Sample rate</string>
<string name="avc">AVC</string>
<string name="yes">Так</string>
<string name="no">Ні</string>
<string name="sdr">SDR</string>
<string name="hdr">HDR</string>
<string name="hdr10">HDR10</string>
<string name="hdr10_plus">HDR10+</string>
<string name="hlg">HLG</string>
<string name="bit_unit">біт</string>
<string name="sample_rate_unit">Гц</string>
<string name="show_titles">Показати заголовок</string>
<string name="live_tv_repeat">Повторити</string>
<string name="favorite_channels_at_beginning">Показати обрані канали на початку</string>
<string name="sort_channels_recently_watched">Відсортувати канали за нещодавно переглянутими</string>
<string name="color_code_programs">Кольоровий код програм</string>
<string name="subtitle_delay">Затримка субтитрів</string>
<string name="backdrop_display">Стиль фону</string>
<string name="resolution_switching">Перемикання розширення</string>
<string name="local">Локальний</string>
<string name="play_trailer">Відтворити трейлер</string>
<string name="no_trailers">Трейлери відсутні</string>
<string name="clear_track_choices">Чіткий вибір доріжки</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
<string name="dts">DTS</string>
<string name="force_dovi_profile_7">Пряме відтворення Dolby Vision Profile 7</string>
<string name="force_dovi_profile_7_summary">Ігноруваии перевірку сумісності пристроїв</string>
<string name="discover">Відкрийте для себе</string>
<string name="request">Запит</string>
<string name="pending">В очікувані</string>
<string name="seerr_integration">Seerr інтеграція</string>
<string name="remove_seerr_server">Видалити Seerr Server</string>
<string name="seerr_server_added">Seerr сервер додано</string>
<string name="password">Пароль</string>
<string name="username">Нік</string>
<string name="url">URL</string>
<string name="trending">Популярні</string>
<string name="upcoming_movies">Заплановані Фільми</string>
<string name="upcoming_tv">Заплановані Серіали</string>
<string name="request_4k">Запит в 4К</string>
<string name="software_decoding_av1">Програмне декодування AV1</string>
<string name="upgrade_mpv_toast">MPV тепер є плеєром за замовчуванням, за винятком для HDR.\nВи можете змінити це в налаштуваннях.</string>
</resources>

View file

@ -254,9 +254,9 @@
<string name="reset">重置</string> <string name="reset">重置</string>
<string name="bold_font">粗体</string> <string name="bold_font">粗体</string>
<string name="player_backend">播放后端</string> <string name="player_backend">播放后端</string>
<string name="mpv_hardware_decoding">MPV:使用硬件解码</string> <string name="mpv_hardware_decoding">mpv:使用硬件解码</string>
<string name="disable_if_crash">如果遇到崩溃,请禁用</string> <string name="disable_if_crash">如果遇到崩溃,请禁用</string>
<string name="mpv_options">MPV 选项</string> <string name="mpv_options">mpv 选项</string>
<string name="exoplayer_options">ExoPlayer 选项</string> <string name="exoplayer_options">ExoPlayer 选项</string>
<string name="skip_segment_unknown">跳过片段</string> <string name="skip_segment_unknown">跳过片段</string>
<string name="skip_segment_commercial">跳过广告</string> <string name="skip_segment_commercial">跳过广告</string>
@ -271,7 +271,7 @@
<string name="remove">移除</string> <string name="remove">移除</string>
<string name="dolby_vision">杜比视界</string> <string name="dolby_vision">杜比视界</string>
<string name="dolby_atmos">杜比全景声</string> <string name="dolby_atmos">杜比全景声</string>
<string name="mpv_use_gpu_next">MPV:使用 gpu-next</string> <string name="mpv_use_gpu_next">mpv:使用 gpu-next</string>
<string name="mpv_conf">编辑 mpv.conf</string> <string name="mpv_conf">编辑 mpv.conf</string>
<string name="discard_change">舍弃更改?</string> <string name="discard_change">舍弃更改?</string>
<string name="subtitle_margin">边距</string> <string name="subtitle_margin">边距</string>
@ -343,4 +343,51 @@
<string name="no_servers_found">未找到服务器</string> <string name="no_servers_found">未找到服务器</string>
<string name="backdrop_display">背景幕样式</string> <string name="backdrop_display">背景幕样式</string>
<string name="ends_at">结束于 %1$s</string> <string name="ends_at">结束于 %1$s</string>
<string name="resolution_switching">分辨率切换</string>
<string name="local">本地</string>
<string name="play_trailer">播放预告片</string>
<string name="no_trailers">无预告片</string>
<string name="clear_track_choices">清除轨道选择</string>
<string name="only_forced_subtitles">仅强制字幕</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
<string name="dts">DTS</string>
<string name="force_dovi_profile_7">直通播放杜比视界配置文件 7</string>
<string name="force_dovi_profile_7_summary">忽略设备兼容性检查</string>
<string name="discover">发现</string>
<string name="request">请求</string>
<string name="pending">待处理</string>
<string name="seerr_integration">Seerr 集成</string>
<string name="remove_seerr_server">移除 Seerr 服务器</string>
<string name="password">密码</string>
<string name="username">用户名</string>
<string name="url">URL</string>
<string name="trending">热门趋势</string>
<string name="upcoming_movies">即将上映的电影</string>
<string name="upcoming_tv">即将播出的电视节目</string>
<string name="request_4k">请求 4K 版本</string>
<string name="seerr_server_added">Seerr 服务器已添加</string>
<string name="voice_search">语音搜索</string>
<string name="voice_starting">开始</string>
<string name="voice_search_prompt">说话进行搜索</string>
<string name="processing">正在处理</string>
<string name="press_back_to_cancel">按返回键取消</string>
<string name="voice_error_audio">音频录制错误</string>
<string name="voice_error_client">语音识别错误</string>
<string name="voice_error_permissions">需要麦克风权限</string>
<string name="voice_error_network">网络错误</string>
<string name="voice_error_network_timeout">网络超时</string>
<string name="voice_error_no_match">未识别到语音</string>
<string name="voice_error_busy">语音识别繁忙</string>
<string name="voice_error_server">服务器错误</string>
<string name="voice_error_speech_timeout">未检测到语音</string>
<string name="voice_error_unknown">未知错误</string>
<string name="voice_error_start_failed">无法启动语音识别</string>
<string name="voice_error_timeout">语音识别超时</string>
<string name="retry">重试</string>
<string name="software_decoding_av1">AV1 软件解码</string>
<string name="upgrade_mpv_toast">除 HDR 视频外mpv 现在是默认播放器。\n您可以在设置中更改此设置。</string>
</resources> </resources>

View file

@ -343,4 +343,50 @@
<string name="sample_rate">取樣率</string> <string name="sample_rate">取樣率</string>
<string name="seek_bar_steps">進度條跳轉值</string> <string name="seek_bar_steps">進度條跳轉值</string>
<string name="playback_overrides">播放覆蓋設定</string> <string name="playback_overrides">播放覆蓋設定</string>
<string name="resolution_switching">解析度切換</string>
<string name="play_trailer">播放預告片</string>
<string name="local">本地</string>
<string name="no_trailers">無預告片</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
<string name="dts">DTS</string>
<string name="force_dovi_profile_7">直接播放杜比視界 Profile 7</string>
<string name="force_dovi_profile_7_summary">忽略設備相容性檢查</string>
<string name="discover">探索</string>
<string name="request">請求</string>
<string name="pending">待處理</string>
<string name="seerr_integration">Seerr 整合</string>
<string name="remove_seerr_server">移除 Seerr 伺服器</string>
<string name="seerr_server_added">已新增 Seerr 伺服器</string>
<string name="password">密碼</string>
<string name="username">使用者名稱</string>
<string name="url">URL</string>
<string name="trending">當前趨勢</string>
<string name="upcoming_movies">即將上映的電影</string>
<string name="upcoming_tv">即將播出的電視劇</string>
<string name="request_4k">請求 4K 版本</string>
<string name="only_forced_subtitles">僅強制字幕</string>
<string name="clear_track_choices">清除音軌/字幕選擇</string>
<string name="voice_search">語音搜尋</string>
<string name="voice_starting">開始</string>
<string name="voice_search_prompt">請說出搜尋內容</string>
<string name="processing">處理中</string>
<string name="press_back_to_cancel">按返回鍵取消</string>
<string name="voice_error_audio">語音錄製錯誤</string>
<string name="voice_error_client">語音辨識錯誤</string>
<string name="voice_error_permissions">請開啟麥克風權限</string>
<string name="voice_error_network">網路錯誤</string>
<string name="voice_error_network_timeout">網路逾時</string>
<string name="voice_error_no_match">未辨識到語音</string>
<string name="voice_error_busy">語音辨識忙碌中</string>
<string name="voice_error_server">伺服器錯誤</string>
<string name="voice_error_speech_timeout">未偵測到語音</string>
<string name="voice_error_unknown">未知錯誤</string>
<string name="voice_error_start_failed">無法啟動語音辨識</string>
<string name="voice_error_timeout">語音辨識逾時</string>
<string name="retry">重試</string>
<string name="software_decoding_av1">AV1 軟體解碼</string>
</resources> </resources>

View file

@ -461,6 +461,7 @@
<string name="upcoming_tv">Upcoming TV Shows</string> <string name="upcoming_tv">Upcoming TV Shows</string>
<string name="request_4k">Request in 4K</string> <string name="request_4k">Request in 4K</string>
<string name="software_decoding_av1">AV1 software decoding</string> <string name="software_decoding_av1">AV1 software decoding</string>
<string name="upgrade_mpv_toast">MPV is now the default player except for HDR.\nYou can change this in settings.</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>
@ -532,8 +533,15 @@
</string-array> </string-array>
<string-array name="player_backend_options"> <string-array name="player_backend_options">
<item>ExoPlayer (default)</item> <item>ExoPlayer</item>
<item>MPV (Experimental)</item> <item>MPV</item>
<item>Prefer MPV</item>
</string-array>
<string-array name="player_backend_options_subtitles">
<item />
<item />
<item>Use ExoPlayer for HDR playback</item>
</string-array> </string-array>
<string-array name="aspect_ratios"> <string-array name="aspect_ratios">

View file

@ -897,8 +897,6 @@ components:
- Bloopers - Bloopers
site: site:
type: string type: string
enum:
- 'YouTube'
MovieDetails: MovieDetails:
type: object type: object
properties: properties:
@ -1226,6 +1224,10 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/WatchProviders' $ref: '#/components/schemas/WatchProviders'
relatedVideos:
type: array
items:
$ref: '#/components/schemas/RelatedVideo'
MediaRequest: MediaRequest:
type: object type: object
properties: properties:

View file

@ -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)
}
}

View file

@ -355,7 +355,7 @@ class TestStreamChoiceServiceSmart(
userAudioLang = null, userAudioLang = null,
), ),
TestInput( TestInput(
1, null,
SubtitlePlaybackMode.SMART, SubtitlePlaybackMode.SMART,
subtitles = subtitles =
listOf( listOf(
@ -367,6 +367,18 @@ class TestStreamChoiceServiceSmart(
userSubtitleLang = "eng", userSubtitleLang = "eng",
userAudioLang = null, 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( TestInput(
2, 0,
SubtitlePlaybackMode.SMART, SubtitlePlaybackMode.SMART,
subtitles = subtitles =
listOf( listOf(

View file

@ -13,10 +13,10 @@ kotlinxCoroutinesCore = "1.10.2"
ksp = "2.3.0" ksp = "2.3.0"
coreKtx = "1.17.0" coreKtx = "1.17.0"
appcompat = "1.7.1" appcompat = "1.7.1"
composeBom = "2025.12.01" composeBom = "2026.01.01"
mockk = "1.14.7" mockk = "1.14.7"
robolectric = "4.14.1" robolectric = "4.16.1"
multiplatformMarkdownRenderer = "0.39.0" multiplatformMarkdownRenderer = "0.39.1"
okhttpBom = "5.3.2" okhttpBom = "5.3.2"
programguide = "1.6.0" programguide = "1.6.0"
slf4j2Timber = "1.2" slf4j2Timber = "1.2"
@ -24,8 +24,8 @@ timber = "5.0.1"
tvFoundation = "1.0.0-alpha12" tvFoundation = "1.0.0-alpha12"
tvMaterial = "1.0.1" tvMaterial = "1.0.1"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.2" activityCompose = "1.12.3"
androidx-media3 = "1.9.0" androidx-media3 = "1.9.1"
coil = "3.3.0" coil = "3.3.0"
jellyfin-sdk = "1.7.1" jellyfin-sdk = "1.7.1"
nav3Core = "1.0.0" nav3Core = "1.0.0"
@ -33,15 +33,15 @@ lifecycleViewmodelNav3 = "2.10.0"
material3AdaptiveNav3 = "1.0.0-alpha03" material3AdaptiveNav3 = "1.0.0-alpha03"
protobuf = "0.9.6" protobuf = "0.9.6"
datastore = "1.2.0" datastore = "1.2.0"
kotlinx-serialization = "1.9.0" kotlinx-serialization = "1.10.0"
protobuf-javalite = "4.33.2" protobuf-javalite = "4.33.4"
hilt = "2.57.2" hilt = "2.58"
room = "2.8.4" room = "2.8.4"
preferenceKtx = "1.2.1" preferenceKtx = "1.2.1"
tvprovider = "1.1.0" tvprovider = "1.1.0"
workRuntimeKtx = "2.11.0" workRuntimeKtx = "2.11.1"
paletteKtx = "1.0.0" paletteKtx = "1.0.0"
openapi-generator = "7.18.0" openapi-generator = "7.19.0"
assMedia = "0.4.0-beta01" assMedia = "0.4.0-beta01"
[libraries] [libraries]