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
<!-- Describe the changes in detail -->
### 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 -->
### Screenshots
### Testing
<!-- Describe how this change was tested and on what device(s) -->
## Screenshots
<!-- Please include screenshots if the PR alters any UI elements -->
### AI/LLM usage
<!-- If you used any AI/LLM assistance, please describe where in the code and how you tested it -->
## AI or LLM usage
<!-- 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.
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.
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">
<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
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows
- Option to combine Continue Watching & Next Up rows
- Show Movie/TV Show titles when browsing libraries
- Play theme music, if available
- Customize subtitle style for plain text subtitles
- Search & download subtitles (requires compatible server plugin such as [OpenSubtitles](https://github.com/jellyfin/jellyfin-plugin-opensubtitles))
- Customize layout grids for libraries
- Multiple app color themes
- Protect user profile switches with PIN code
### Playback
- Different media playback engines, including:
- Default ExoPlayer/Media3
- Experimental MPV
- Plex inspired playback controls, such as:
- Different media playback engines:
- **ExoPlayer** w/ optional extra audio & AV1 software decoding
- **MPV** for direct playing anything plus ASS subtitle support
- Plex inspired playback controls:
- Using D-Pad left/right for seeking during playback
- Quickly access video chapters & queue during playback
- Optionally skip back a few seconds when resuming playback
- Live TV & DVR support
- Auto play next episodes with pass out protection
- Option for automatic refresh rate switching on supported displays
- Option for automatic refresh rate & resolution switching on supported displays
- Trickplay support
- Other (subjective) enhancements:
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
- Force Continue Watching & Next Up TV episodes to use their Series posters
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
### Roadmap
@ -61,6 +63,8 @@ See [here for the roadmap](https://github.com/damontecres/Wholphin/wiki#roadmap)
## Installation
Using [Google Play](https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin) or [Amazon appstore](https://www.amazon.com/gp/product/B0G8RQQR9T/ref=mas_pm_wholphin) are the fastest way to install. But you can follow these instructions to install without needing an app store
Downloader Code: `8668671`
1. Enable side-loading "unknown" apps
@ -68,7 +72,7 @@ Downloader Code: `8668671`
- https://www.xda-developers.com/how-to-sideload-apps-android-tv/
- https://developer.android.com/distribute/marketing-tools/alternative-distribution#unknown-sources
- https://www.aftvnews.com/how-to-enable-apps-from-unknown-sources-on-an-amazon-fire-tv-or-fire-tv-stick/
1. Install the APK on your Android TV device with one of these options:
2. Install the APK on your Android TV device with one of these options:
- Install a browser program such as [Downloader](https://www.aftvnews.com/downloader/), use it to get the latest apk with short code `8668671` or URL: http://aftv.news/8668671
- Download the latest APK release from the [releases page](https://github.com/damontecres/Wholphin/releases/latest) or http://aftv.news/8668671
- Put the APK on an SD Card/USB stick/network share and use a file manager app from the Google Play Store / Amazon AppStore (e.g. `FX File Explorer`). Android's preinstalled file manager probably will not work!
@ -81,12 +85,16 @@ After the initial install above, the app will automatically check for updates. T
The first time you attempt an update, the OS should guide you through enabling the required additional permissions for the app to install updates.
Note: if installed via an app store, the app store will handle updates.
## Compatibility
Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x` (tested on primarily `10.11.3`).
Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x` (tested on primarily `10.11`).
The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue!
Jellyseerr integration is tested with `v2.7.3`. Older versions may not work.
## Contributions
Issues and pull requests are always welcome! Please check before submitting that your issue or pull request is not a duplicate.

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<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.RECORD_AUDIO" />

View file

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

View file

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

View file

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

View file

@ -39,6 +39,17 @@ val DefaultForGenresFilterOptions =
DecadeFilter,
)
val DefaultPlaylistItemsOptions =
listOf(
PlayedFilter,
FavoriteFilter,
CommunityRatingFilter,
OfficialRatingFilter,
VideoTypeFilter,
YearFilter,
DecadeFilter,
)
sealed interface ItemFilterBy<T> {
@get:StringRes
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.buildAnnotatedString
import com.github.damontecres.wholphin.ui.DateFormatter
import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.dot
@ -71,8 +72,7 @@ data class BaseItem(
@Transient
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
@Transient
val indexNumber = data.indexNumber ?: dateAsIndex()
val indexNumber get() = data.indexNumber
val playbackPosition get() = data.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
@ -88,6 +88,15 @@ data class BaseItem(
@Transient
val ui =
BaseItemUi(
episodeCornerText =
data.indexNumber?.let { "E$it" }
?: data.premiereDate?.let(::formatDateTime),
episdodeUnplayedCornerText =
data.indexNumber?.let { "E$it" }
?: data.userData
?.unplayedItemCount
?.takeIf { it > 0 }
?.let { abbreviateNumber(it) },
quickDetails =
buildAnnotatedString {
val details =
@ -191,5 +200,7 @@ val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let {
@Immutable
data class BaseItemUi(
val episodeCornerText: String?,
val episdodeUnplayedCornerText: String?,
val quickDetails: AnnotatedString,
)

View file

@ -752,10 +752,23 @@ sealed interface AppPreference<Pref, T> {
prefs.updatePlaybackPreferences { playerBackend = value }
},
displayValues = R.array.player_backend_options,
subtitles = R.array.player_backend_options_subtitles,
indexToValue = { PlayerBackend.forNumber(it) },
valueToIndex = { it.number },
)
val ExoPlayerSettings =
AppDestinationPreference<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 =
AppSwitchPreference<AppPreferences>(
title = R.string.mpv_hardware_decoding,
@ -770,7 +783,7 @@ sealed interface AppPreference<Pref, T> {
val MpvGpuNext =
AppSwitchPreference<AppPreferences>(
title = R.string.mpv_use_gpu_next,
defaultValue = true,
defaultValue = false,
getter = { it.playbackPreferences.mpvOptions.useGpuNext },
setter = { prefs, value ->
prefs.updateMpvOptions { useGpuNext = value }
@ -932,8 +945,7 @@ val basicPreferences =
title = R.string.profile_specific_settings,
preferences =
listOf(
// TODO PIN-related
// AppPreference.RequireProfilePin,
AppPreference.RequireProfilePin,
AppPreference.UserPinnedNavDrawerItems,
),
),
@ -959,6 +971,40 @@ val basicPreferences =
val uiPreferences = listOf<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 =
buildList {
add(
@ -1009,22 +1055,17 @@ val advancedPreferences =
listOf(
ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.EXO_PLAYER },
listOf(
AppPreference.FfmpegPreference,
AppPreference.DownMixStereo,
AppPreference.Ac3Supported,
AppPreference.DirectPlayAss,
AppPreference.DirectPlayPgs,
AppPreference.DirectPlayDoviProfile7,
AppPreference.DecodeAv1,
),
ExoPlayerSettings,
),
ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.MPV },
MpvSettings,
),
ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.PREFER_MPV },
listOf(
AppPreference.MpvHardwareDecoding,
AppPreference.MpvGpuNext,
AppPreference.MpvConfFile,
AppPreference.ExoPlayerSettings,
AppPreference.MpvSettings,
),
),
),
@ -1109,6 +1150,7 @@ data class AppChoicePreference<Pref, T>(
override val getter: (prefs: Pref) -> T,
override val setter: (prefs: Pref, value: T) -> Pref,
@param:StringRes val summary: Int? = null,
@param:ArrayRes val subtitles: Int? = null,
) : AppPreference<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) {
backStack.removeLastOrNull()
}
if (backStack.isEmpty()) {
backStack.add(0, destination)
} else {
backStack[0] = destination
}
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.AppPreferences
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import dagger.hilt.android.qualifiers.ApplicationContext
@ -31,8 +32,8 @@ import io.github.peerless2012.ass.media.factory.AssRenderersFactory
import io.github.peerless2012.ass.media.kt.withAssMkvSupport
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
import io.github.peerless2012.ass.media.type.AssRenderType
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.lang.reflect.Constructor
import javax.inject.Inject
@ -52,27 +53,25 @@ class PlayerFactory
var currentPlayer: Player? = null
private set
fun createVideoPlayer(): PlayerCreation {
suspend fun createVideoPlayer(
backend: PlayerBackend,
prefs: PlaybackPreferences,
): PlayerCreation {
withContext(Dispatchers.Main) {
if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release()
}
}
var assHandler: AssHandler? = null
val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences }
val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue
val newPlayer =
when (backend) {
PlayerBackend.MPV -> {
val enableHardwareDecoding =
prefs?.mpvOptions?.enableHardwareDecoding
?: AppPreference.MpvHardwareDecoding.defaultValue
val useGpuNext =
prefs?.mpvOptions?.useGpuNext
?: AppPreference.MpvGpuNext.defaultValue
PlayerBackend.PREFER_MPV,
PlayerBackend.MPV,
-> {
val enableHardwareDecoding = prefs.mpvOptions.enableHardwareDecoding
val useGpuNext = prefs.mpvOptions.useGpuNext
MpvPlayer(context, enableHardwareDecoding, useGpuNext)
.apply {
playWhenReady = true
}
}
PlayerBackend.EXO_PLAYER,
@ -122,7 +121,6 @@ class PlayerFactory
.build()
.apply {
assHandler?.init(this)
playWhenReady = true
}
}
}

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

View file

@ -30,6 +30,7 @@ import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
/**
* Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed.
@ -137,7 +138,17 @@ class SeerrServerRepository
username: String?,
passwordOrApiKey: String,
): LoadingState {
val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient)
val apiKey = passwordOrApiKey.takeIf { authMethod == SeerrAuthMethod.API_KEY }
val api =
SeerrApiClient(
url,
apiKey,
okHttpClient
.newBuilder()
.connectTimeout(5.seconds)
.readTimeout(6.seconds)
.build(),
)
login(api, authMethod, username, passwordOrApiKey)
return LoadingState.Success
}
@ -185,6 +196,7 @@ private suspend fun login(
password = password ?: "",
),
)
client.usersApi.authMeGet()
}
SeerrAuthMethod.JELLYFIN -> {
@ -194,6 +206,7 @@ private suspend fun login(
password = password ?: "",
),
)
client.usersApi.authMeGet()
}
SeerrAuthMethod.API_KEY -> {

View file

@ -276,10 +276,16 @@ class StreamChoiceService
SubtitlePlaybackMode.SMART -> {
if (subtitleLanguage.isNotNullOrBlank()) {
val audioLanguage = userConfig?.audioLanguagePreference
if (audioLanguage.isNullOrBlank() || audioLanguage != audioStreamLang) {
if (
// Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different
(audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) ||
// Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio
(audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang)
) {
candidates.firstOrNull { it.language == subtitleLanguage }
?: candidates.firstOrNull { it.language.isUnknown }
} else {
// Otherwise, show forced subtitles in preferred lang
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
}

View file

@ -56,7 +56,6 @@ import kotlin.contracts.contract
import kotlin.coroutines.CoroutineContext
import kotlin.math.min
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@ -234,7 +233,7 @@ val Duration.roundMinutes: Duration
* Rounds a [Duration] to nearest whole second
*/
val Duration.roundSeconds: Duration
get() = (this + 30.milliseconds).inWholeSeconds.seconds
get() = (this + .5.seconds).inWholeSeconds.seconds
/**
* Gets the user's playback position as a [Duration]

View file

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

View file

@ -47,18 +47,9 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter
import com.github.damontecres.wholphin.data.filter.DecadeFilter
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.FavoriteFilter
import com.github.damontecres.wholphin.data.filter.FilterValueOption
import com.github.damontecres.wholphin.data.filter.FilterVideoType
import com.github.damontecres.wholphin.data.filter.GenreFilter
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter
import com.github.damontecres.wholphin.data.filter.PlayedFilter
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
import com.github.damontecres.wholphin.data.filter.YearFilter
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
@ -88,6 +79,7 @@ import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.FilterUtils
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -103,9 +95,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.localizationApi
import org.jellyfin.sdk.api.client.extensions.yearsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
@ -116,7 +105,6 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.TreeSet
import java.util.UUID
import kotlin.time.Duration
@ -391,79 +379,12 @@ class CollectionFolderViewModel
}
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
try {
when (filterOption) {
GenreFilter -> {
api.genresApi
.getGenres(
parentId = itemUuid,
userId = serverRepository.currentUser.value?.id,
).content.items
.map { FilterValueOption(it.name ?: "", it.id) }
}
FavoriteFilter,
PlayedFilter,
-> {
listOf(
FilterValueOption("True", null),
FilterValueOption("False", null),
FilterUtils.getFilterOptionValues(
api,
serverRepository.currentUser.value?.id,
itemUuid,
filterOption,
)
}
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? =
withContext(Dispatchers.IO) {

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

@ -40,6 +40,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
@ -112,6 +113,9 @@ class SeerrRequestsViewModel
state.update { it.copy(requests = DataLoadingState.Success(results)) }
}
}.catch { ex ->
Timber.e(ex, "Error fetching requests")
state.update { it.copy(requests = DataLoadingState.Error(ex)) }
}.launchIn(viewModelScope)
}

View file

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

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

View file

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

View file

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

View file

@ -4,12 +4,14 @@ import com.github.damontecres.wholphin.data.model.Chapter
import org.jellyfin.sdk.model.api.TrickplayInfo
data class CurrentMediaInfo(
val sourceId: String?,
val videoStream: SimpleVideoStream?,
val audioStreams: List<SimpleMediaStream>,
val subtitleStreams: List<SimpleMediaStream>,
val chapters: List<Chapter>,
val trickPlayInfo: TrickplayInfo?,
) {
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.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TextButton
@ -107,16 +106,16 @@ fun PlaybackPage(
preferences: UserPreferences,
destination: Destination,
modifier: Modifier = Modifier,
viewModel: PlaybackViewModel = hiltViewModel(),
viewModel: PlaybackViewModel =
hiltViewModel<PlaybackViewModel, PlaybackViewModel.Factory>(
creationCallback = { it.create(destination) },
),
) {
LifecycleStartEffect(destination) {
onStopOrDispose {
viewModel.release()
}
}
LaunchedEffect(destination) {
viewModel.init(destination, preferences)
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val st = loading) {
@ -131,12 +130,34 @@ fun PlaybackPage(
}
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 scope = rememberCoroutineScope()
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val player = viewModel.player
val mediaInfo by viewModel.currentMediaInfo.observeAsState()
val userDto by viewModel.currentUserDto.observeAsState()
@ -160,8 +181,8 @@ fun PlaybackPage(
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
OneTimeLaunchedEffect {
if (prefs.playerBackend == PlayerBackend.MPV) {
LaunchedEffect(player) {
if (playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.IO + ExceptionHandler()) {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
configuration,
@ -170,10 +191,11 @@ fun PlaybackPage(
}
}
}
AmbientPlayerListener(player)
var contentScale by remember {
var contentScale by remember(playerBackend) {
mutableStateOf(
if (prefs.playerBackend == PlayerBackend.MPV) {
if (playerBackend == PlayerBackend.MPV) {
ContentScale.FillBounds
} else {
prefs.globalContentScale.scale
@ -402,7 +424,7 @@ fun PlaybackPage(
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f)
}
viewModel.assHandler?.let { assHandler ->
playerState.assHandler?.let { assHandler ->
if (prefs.overrides.directPlayAss) {
Timber.v("Adding AssSubtitleView")
addView(
@ -622,6 +644,4 @@ fun PlaybackPage(
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.RefreshRateService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
@ -58,7 +59,6 @@ import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.checkForSupport
@ -66,6 +66,9 @@ import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
import com.github.damontecres.wholphin.util.profile.Codec
import com.github.damontecres.wholphin.util.subtitleMimeTypes
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.peerless2012.ass.media.AssHandler
@ -101,13 +104,14 @@ import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
import org.jellyfin.sdk.model.api.TrickplayInfo
import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType
import org.jellyfin.sdk.model.extensions.inWholeTicks
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.Date
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@ -115,10 +119,10 @@ import kotlin.time.Duration.Companion.seconds
/**
* This [ViewModel] is responsible for playing media including moving through playlists (including next up episodes)
*/
@HiltViewModel
@HiltViewModel(assistedFactory = PlaybackViewModel.Factory::class)
@OptIn(markerClass = [UnstableApi::class])
class PlaybackViewModel
@Inject
@AssistedInject
constructor(
@param:ApplicationContext internal val context: Context,
internal val api: ApiClient,
@ -133,15 +137,20 @@ class PlaybackViewModel
private val deviceProfileService: DeviceProfileService,
private val refreshRateService: RefreshRateService,
val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService,
@Assisted private val destination: Destination,
) : ViewModel(),
Player.Listener,
AnalyticsListener {
var assHandler: AssHandler? = null
val player by lazy {
val creation = playerFactory.createVideoPlayer()
assHandler = creation.assHandler
creation.player
@AssistedFactory
interface Factory {
fun create(destination: Destination): PlaybackViewModel
}
val currentPlayer = MutableStateFlow<PlayerState?>(null)
internal lateinit var player: Player
private var mediaSession: MediaSession? = null
internal val mutex = Mutex()
@ -178,7 +187,14 @@ class PlaybackViewModel
val currentUserDto = serverRepository.currentUserDto
init {
addCloseable {
viewModelScope.launchIO {
addCloseable { disconnectPlayer() }
init()
}
}
private fun disconnectPlayer() {
if (this@PlaybackViewModel::player.isInitialized) {
player.removeListener(this@PlaybackViewModel)
(player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel)
@ -186,26 +202,72 @@ class PlaybackViewModel
it.release()
player.removeListener(it)
}
jobs.forEach { it.cancel() }
player.release()
mediaSession?.release()
}
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
jobs.forEach { it.cancel() }
}
private suspend fun createPlayer(
isHdr: Boolean,
is4k: Boolean,
) {
val softwareDecoding =
!preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding
val playerBackend =
when (preferences.appPreferences.playbackPreferences.playerBackend) {
PlayerBackend.UNRECOGNIZED,
PlayerBackend.EXO_PLAYER,
-> PlayerBackend.EXO_PLAYER
PlayerBackend.MPV -> PlayerBackend.MPV
PlayerBackend.PREFER_MPV -> if (isHdr || (is4k && softwareDecoding)) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV
}
Timber.d("Selected backend: %s", playerBackend)
if (currentPlayer.value?.backend != playerBackend) {
Timber.i("Switching player backend to %s", playerBackend)
withContext(Dispatchers.Main) {
disconnectPlayer()
}
val playerCreation =
playerFactory.createVideoPlayer(
playerBackend,
preferences.appPreferences.playbackPreferences,
)
this.player = playerCreation.player
currentPlayer.update {
PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler)
}
configurePlayer()
}
}
private fun configurePlayer() {
player.addListener(this)
(player as? ExoPlayer)?.addAnalyticsListener(this)
jobs.add(subscribe())
jobs.add(listenForTranscodeReason())
val sessionPlayer =
MediaSessionPlayer(
player,
controllerViewState,
preferences.appPreferences.playbackPreferences,
)
mediaSession =
MediaSession
.Builder(context, sessionPlayer)
.build()
}
/**
* Initialize from the UI to start playback
*/
fun init(
destination: Destination,
preferences: UserPreferences,
) {
nextUp.value = null
this.preferences = preferences
private suspend fun init() {
nextUp.setValueOnMain(null)
this.preferences = userPreferencesService.getCurrent()
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
addCloseable { refreshRateService.resetRefreshRate() }
}
@ -238,13 +300,6 @@ class PlaybackViewModel
}
}
this.itemId = itemId
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error preparing for playback for $itemId",
),
) {
val queriedItem = api.userLibraryApi.getItem(itemId).content
val base =
if (queriedItem.type.playable) {
@ -263,14 +318,14 @@ class PlaybackViewModel
when (val r = playlistResult) {
is PlaylistCreationResult.Error -> {
loading.setValueOnMain(LoadingState.Error(r.message, r.ex))
return@launch
return
}
is PlaylistCreationResult.Success -> {
if (r.playlist.items.isEmpty()) {
showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
navigationManager.goBack()
return@launch
return
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = r.playlist
@ -284,19 +339,9 @@ class PlaybackViewModel
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
}
val sessionPlayer =
MediaSessionPlayer(
player,
controllerViewState,
preferences.appPreferences.playbackPreferences,
)
mediaSession =
MediaSession
.Builder(context, sessionPlayer)
.build()
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
val item = BaseItem.from(base, api)
val played =
play(
item,
@ -317,7 +362,6 @@ class PlaybackViewModel
}
}
}
}
/**
* Play an item
@ -384,6 +428,21 @@ class PlaybackViewModel
return@withContext false
}
val videoStream =
mediaSource.mediaStreams
?.firstOrNull { it.type == MediaStreamType.VIDEO }
?.let {
val isHdr =
it.videoRange == VideoRange.HDR ||
(it.videoRangeType != VideoRangeType.SDR && it.videoRangeType != VideoRangeType.UNKNOWN)
// Often times 4k movies have a wider aspect ratio so the height is lower even though the width is still 3840
val is4k = (it.width ?: 0) > 2560 || (it.height ?: 0) > 1440
SimpleVideoStream(it.index, isHdr, is4k)
}
// Create the correct player for the media
createPlayer(videoStream?.hdr == true, videoStream?.is4k == true)
val subtitleStreams =
mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
@ -454,6 +513,8 @@ class PlaybackViewModel
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
updateCurrentMedia {
CurrentMediaInfo(
sourceId = mediaSource.id,
videoStream = videoStream,
audioStreams = audioStreams,
subtitleStreams = subtitleStreams,
chapters = chapters,
@ -493,71 +554,18 @@ class PlaybackViewModel
enableDirectStream: Boolean = !this.forceTranscoding,
) = withContext(Dispatchers.IO) {
val itemId = item.id
val playerBackend = preferences.appPreferences.playbackPreferences.playerBackend
val currentPlayback = this@PlaybackViewModel.currentPlayback.value
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
// If direct playing, can try to switch tracks without playback restarting
// Except for external subtitles
// TODO there's probably no reason why we can't add external subtitles?
Timber.v("changeStreams direct play")
val source = currentPlayback.mediaSourceInfo
val externalSubtitle = source.findExternalSubtitle(subtitleIndex)
if (externalSubtitle == null) {
val result =
withContext(Dispatchers.Main) {
TrackSelectionUtils.createTrackSelections(
onMain { player.trackSelectionParameters },
onMain { player.currentTracks },
playerBackend,
true,
audioIndex,
subtitleIndex,
source,
val wasSuccessful =
changeStreamsDirectPlay(
currentPlayback = currentPlayback,
currentItemPlayback = currentItemPlayback,
audioIndex = audioIndex,
subtitleIndex = subtitleIndex,
userInitiated = userInitiated,
)
}
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")
}
if (wasSuccessful) return@withContext
}
Timber.d(
@ -575,7 +583,7 @@ class PlaybackViewModel
PlaybackInfoDto(
startTimeTicks = null,
deviceProfile =
if (playerBackend == PlayerBackend.EXO_PLAYER) {
if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) {
deviceProfileService.getOrCreateDeviceProfile(
preferences.appPreferences.playbackPreferences,
serverRepository.currentServer.value?.serverVersion,
@ -680,7 +688,7 @@ class PlaybackViewModel
CurrentPlayback(
item = item,
tracks = listOf(),
backend = preferences.appPreferences.playbackPreferences.playerBackend,
backend = currentPlayer.value!!.backend,
playMethod = transcodeType,
playSessionId = response.playSessionId,
liveStreamId = source.liveStreamId,
@ -731,7 +739,7 @@ class PlaybackViewModel
TrackSelectionUtils.createTrackSelections(
player.trackSelectionParameters,
player.currentTracks,
playerBackend,
currentPlayer.value!!.backend,
source.supportsDirectPlay,
audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY },
subtitleIndex,
@ -752,6 +760,81 @@ class PlaybackViewModel
}
}
/**
* If direct playing, can try to switch tracks without playback restarting
* Except for external subtitles
*/
@OptIn(UnstableApi::class)
private suspend fun changeStreamsDirectPlay(
currentPlayback: CurrentPlayback,
currentItemPlayback: ItemPlayback,
audioIndex: Int?,
subtitleIndex: Int?,
userInitiated: Boolean,
): Boolean =
withContext(Dispatchers.IO) {
// TODO there's probably no reason why we can't add external subtitles?
Timber.v("changeStreams direct play")
val source = currentPlayback.mediaSourceInfo
val externalSubtitle = source.findExternalSubtitle(subtitleIndex)
if (externalSubtitle == null) {
val result =
withContext(Dispatchers.Main) {
TrackSelectionUtils.createTrackSelections(
onMain { player.trackSelectionParameters },
onMain { player.currentTracks },
currentPlayer.value!!.backend,
true,
audioIndex,
subtitleIndex,
source,
)
}
if (result.bothSelected) {
onMain { player.trackSelectionParameters = result.trackSelectionParameters }
// TODO lots of duplicate code in this block
Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex")
val itemPlayback =
currentItemPlayback.copy(
sourceId = source.id?.toUUIDOrNull(),
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
// Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index
subtitleIndex =
if (currentItemPlayback.subtitleIndex < 0) {
currentItemPlayback.subtitleIndex
} else {
subtitleIndex ?: TrackIndex.DISABLED
},
)
if (userInitiated) {
viewModelScope.launchIO {
Timber.v("Saving user initiated item playback: %s", itemPlayback)
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentItemPlayback.value = updated
}
}
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentPlayback.update {
(it ?: currentPlayback).copy(
tracks = checkForSupport(player.currentTracks),
)
}
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
}
loadSubtitleDelay()
return@withContext true
}
} else {
Timber.v("changeStreams direct play, external subtitle was requested")
}
return@withContext false
}
fun changeAudioStream(index: Int) {
viewModelScope.launchIO {
Timber.d("Changing audio track to %s", index)
@ -1118,9 +1201,7 @@ class PlaybackViewModel
fun release() {
Timber.v("release")
activityListener?.release()
player.release()
mediaSession?.release()
disconnectPlayer()
activityListener = null
}
@ -1296,3 +1377,9 @@ class PlaybackViewModel
}
}
}
data class PlayerState(
val player: Player,
val backend: PlayerBackend,
val assHandler: AssHandler?,
)

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -38,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.Button
@ -51,10 +53,13 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import java.util.UUID
/**
* Display a list of users plus option to add a new one or switch servers
@ -285,6 +290,65 @@ private fun UserIconCard(
}
}
@Composable
fun UserIconCardImage(
id: UUID,
name: String?,
imageUrl: String?,
modifier: Modifier = Modifier,
alpha: Float = 1f,
) {
var imageError by remember { mutableStateOf(false) }
val userColor = rememberIdColor(id, alpha)
Box(
modifier =
modifier.background(
color = userColor,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
if (imageUrl.isNotNullOrBlank() && !imageError) {
AsyncImage(
model = imageUrl,
contentDescription = name,
contentScale = ContentScale.Crop,
onError = { imageError = true },
modifier =
Modifier
.fillMaxSize()
.clip(CircleShape),
)
} else {
val firstLetter =
remember(id, name) {
(name ?: id.toString()).firstOrNull()?.uppercase() ?: "?"
}
Text(
text = firstLetter,
style = MaterialTheme.typography.bodyLarge,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
}
}
}
@PreviewTvSpec
@Composable
fun UserIconCardImagePreview() {
WholphinTheme {
UserIconCardImage(
id = UUID.randomUUID(),
name = "A smith",
imageUrl = null,
modifier = Modifier.size(24.dp),
)
}
}
/**
* Add User card component - displays a + icon in a circle
*/

View file

@ -6,10 +6,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -30,6 +32,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -240,7 +243,7 @@ fun AddSeerrServerUsername(
},
),
isInputValid = { true },
modifier = Modifier.focusRequester(focusRequester),
modifier = Modifier.focusRequester(usernameFocusRequester),
)
}
Row(
@ -283,13 +286,24 @@ fun AddSeerrServerUsername(
style = MaterialTheme.typography.titleLarge,
)
}
TextButton(
stringRes = R.string.submit,
Button(
onClick = { onSubmit.invoke(url, username, password) },
enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && password.isNotNullOrBlank(),
enabled =
error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() &&
status != LoadingState.Loading,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
if (status != LoadingState.Loading) {
Text(text = stringResource(R.string.submit))
} else {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier =
Modifier.size(24.dp),
)
}
}
}
}
@PreviewTvSpec

View file

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

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.setup.seerr
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
@ -8,95 +9,96 @@ import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber
@HiltViewModel
class SwitchSeerrViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val seerrServerRepository: SeerrServerRepository,
private val seerrService: SeerrService,
private val serverRepository: ServerRepository,
) : ViewModel() {
val currentUser = serverRepository.currentUser
val currentSeerrServer = seerrServerRepository.currentServer
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
private fun cleanUrl(url: String) =
if (!url.endsWith("/api/v1")) {
url
.toHttpUrlOrNull()
?.newBuilder()
?.apply {
addPathSegment("api")
addPathSegment("v1")
}?.build()
.toString()
} else {
url
}
fun submitServer(
url: String,
apiKey: String,
) {
viewModelScope.launchIO {
val url = cleanUrl(url)
val result =
try {
seerrServerRepository.testConnection(
authMethod = SeerrAuthMethod.API_KEY,
url = url,
username = null,
passwordOrApiKey = apiKey,
)
} catch (ex: ClientException) {
Timber.w(ex, "Error logging in via API Key")
if (ex.statusCode == 401 || ex.statusCode == 403) {
LoadingState.Error("Invalid credentials", ex)
} else {
LoadingState.Error(ex)
}
}
if (result is LoadingState.Success) {
seerrServerRepository.addAndChangeServer(url, apiKey)
}
serverConnectionStatus.update { result }
}
}
fun submitServer(
url: String,
username: String,
password: String,
passwordOrApiKey: String,
authMethod: SeerrAuthMethod,
) {
viewModelScope.launchIO {
val url = cleanUrl(url)
val result =
serverConnectionStatus.update { LoadingState.Loading }
val urls =
try {
createUrls(url)
} catch (ex: IllegalArgumentException) {
showToast(context, "Invalid URL")
serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) }
return@launchIO
}
var result: LoadingState = LoadingState.Error("No url")
for (url in urls) {
Timber.d("Trying %s", url)
result =
try {
seerrServerRepository.testConnection(
authMethod = authMethod,
url = url,
username = username,
passwordOrApiKey = password,
url = url.toString(),
username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY },
passwordOrApiKey = passwordOrApiKey,
)
} 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) {
LoadingState.Error("Invalid credentials", ex)
showToast(context, "Invalid credentials")
result = LoadingState.Error("Invalid credentials", ex)
break
} else {
LoadingState.Error("Could not connect with URL")
}
} catch (ex: Exception) {
Timber.w(ex, "Exception logging in")
LoadingState.Error(ex)
}
}
if (result is LoadingState.Success) {
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 }
}
@ -112,3 +114,39 @@ class SwitchSeerrViewModel
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.remember
import com.github.damontecres.wholphin.ui.TimeFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.time.LocalDateTime
val LocalClock = compositionLocalOf<Clock> { Clock() }
@ -32,6 +34,7 @@ data class Clock(
fun ProvideLocalClock(content: @Composable () -> Unit) {
val clock = remember { Clock() }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
while (isActive) {
val now = LocalDateTime.now()
val time = TimeFormatter.format(now)
@ -40,5 +43,6 @@ fun ProvideLocalClock(content: @Composable () -> Unit) {
delay(2_000)
}
}
}
CompositionLocalProvider(LocalClock provides clock, content)
}

View file

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

View file

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

View file

@ -32,6 +32,7 @@ enum MediaExtensionStatus{
enum PlayerBackend{
EXO_PLAYER = 0;
MPV = 1;
PREFER_MPV = 2;
}
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>
<string name="about">Über diese App</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_user">Benutzer hinzufügen</string>
<string name="audio">Ton</string>
@ -56,7 +56,7 @@
<string name="tv_shows">Serien</string>
<string name="ui_interface">Oberfläche</string>
<string name="unknown">Unbekannt</string>
<string name="updates">Updates</string>
<string name="updates">Aktualisierungen</string>
<string name="version">Version</string>
<string name="watch_live">Live ansehen</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="check_for_updates">Auf Aktualisierungen prüfen</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="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="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="downloading">Herunterladen…</string>
<string name="enabled">Aktiviert</string>
@ -118,7 +118,7 @@
<string name="next_up">Als nächstes</string>
<string name="none">Keine</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">Wiedergabe</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">Untertitel</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="tv_guide">Fernsehprogramm</string>
<string name="play_with_transcoding">Mit Transkodierung abspielen</string>
@ -207,12 +207,12 @@
<item quantity="other"/>
</plurals>
<plurals name="clips">
<item quantity="one">Clips</item>
<item quantity="other"/>
<item quantity="one">Clip</item>
<item quantity="other">Clips</item>
</plurals>
<plurals name="interviews">
<item quantity="one">Interviews</item>
<item quantity="other"/>
<item quantity="one">Interview</item>
<item quantity="other">Interviews</item>
</plurals>
<plurals name="samples">
<item quantity="one">Beispiele</item>
@ -321,4 +321,67 @@
<string name="no">Nein</string>
<string name="show_titles">Titel anzeigen</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>

View file

@ -179,9 +179,9 @@
<item quantity="other"/>
</plurals>
<plurals name="clips">
<item quantity="one">Clips</item>
<item quantity="many"/>
<item quantity="other"/>
<item quantity="one">Clip</item>
<item quantity="many">Clips</item>
<item quantity="other"></item>
</plurals>
<plurals name="deleted_scenes">
<item quantity="one">Escenas eliminadas</item>
@ -375,4 +375,50 @@
<string name="color_code_programs">Programas codificados por colores</string>
<string name="subtitle_delay">Retardo de subtítulos</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>

View file

@ -359,4 +359,51 @@
<string name="no_servers_found">Ühtegi serverit ei leidu</string>
<string name="backdrop_display">Tausta dekoratsioon</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>

View file

@ -11,7 +11,7 @@
<string name="delete">Supprimer</string>
<string name="director">Réalisateur</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="birthplace">Lieu de naissance</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="install_update">Installer la mise à jour</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">Personnaliser les éléments du menu de navigation</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="aspect_ratio">Format d\'image</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="edge_size">Taille des bordures</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="subtitle_delay">Décalage des sous-titres</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>

View file

@ -11,7 +11,7 @@
<string name="cancel">Mégse</string>
<string name="chapters">Fejezetek</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="collections">Gyűjtemények</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="install_update">Frissítés telepítése</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">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>
@ -279,7 +279,7 @@
<string name="skip_segment_recap">Összefoglaló átugrása</string>
<string name="skip_segment_outro">Stáblista á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="year">Év</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="combine_continue_next"><![CDATA[Folytatás & Következő menü összevonása]]></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="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="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>

View file

@ -121,9 +121,9 @@
<string name="switch_servers">Ganti Server</string>
<string name="switch_user">Ganti</string>
<string name="top_unwatched">Terbaik Belum Ditonton</string>
<string name="trailer">Cuplikan</string>
<string name="trailer">Trailer</string>
<plurals name="trailers">
<item quantity="other">Cuplikan</item>
<item quantity="other">Trailer</item>
</plurals>
<string name="tv_dvr_schedule">Jadwal DVR</string>
<string name="tv_guide">Panduan</string>
@ -291,11 +291,11 @@
<string name="guest_stars">Pemeran Tamu</string>
<string name="anamorphic">Anamorfik</string>
<string name="interlaced">Interlace</string>
<string name="framerate">Framerate</string>
<string name="bit_depth">Kedalaman Bit</string>
<string name="video_range">Rentang Video</string>
<string name="framerate">Laju frame</string>
<string name="bit_depth">Kedalaman bit</string>
<string name="video_range">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="nal">NAL</string>
<string name="language">Bahasa</string>
@ -336,4 +336,57 @@
<string name="enter_server_address">Masukkan alamat server</string>
<string name="no_servers_found">Server tidak ditemukan</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>

View file

@ -375,4 +375,51 @@
<string name="no_servers_found">Nessun server trovato</string>
<string name="backdrop_display">Stile backdrop</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>

View file

@ -1,4 +1,367 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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>

View file

@ -375,4 +375,51 @@
<string name="no_servers_found">Nenhum servidor encontrado</string>
<string name="backdrop_display">Estilo de fundo</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>

View file

@ -299,15 +299,15 @@
</plurals>
<plurals name="samples">
<item quantity="one">Отрывки</item>
<item quantity="few"></item>
<item quantity="many"></item>
<item quantity="other"></item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="featurettes">
<item quantity="one">Дополнительные материалы</item>
<item quantity="few"></item>
<item quantity="many"></item>
<item quantity="other"></item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<string name="player_backend">Выбор плеера</string>
<string name="edge_size">Толщина обводки</string>
@ -316,15 +316,15 @@
<string name="forced_track">Форсированные</string>
<plurals name="theme_songs">
<item quantity="one">Саундтреки</item>
<item quantity="few"></item>
<item quantity="many"></item>
<item quantity="other"></item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<plurals name="theme_videos">
<item quantity="one">Видеоролики</item>
<item quantity="few"></item>
<item quantity="many"></item>
<item quantity="other"></item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</plurals>
<string name="nav_drawer_pins">Настроить элементы боковой панели</string>
<string name="nav_drawer_pins_summary">Выберите элементы для показа на панели, остальные элементы будут скрыты</string>
@ -339,8 +339,58 @@
<string name="pass_out_protection">Остановка воспроизведения при бездействии</string>
<plurals name="shorts">
<item quantity="one">Короткометражки</item>
<item quantity="few"></item>
<item quantity="many"></item>
<item quantity="other"></item>
<item quantity="few"/>
<item quantity="many"/>
<item quantity="other"/>
</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>

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="bold_font">粗体</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="mpv_options">MPV 选项</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>
@ -271,7 +271,7 @@
<string name="remove">移除</string>
<string name="dolby_vision">杜比视界</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="discard_change">舍弃更改?</string>
<string name="subtitle_margin">边距</string>
@ -343,4 +343,51 @@
<string name="no_servers_found">未找到服务器</string>
<string name="backdrop_display">背景幕样式</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>

View file

@ -343,4 +343,50 @@
<string name="sample_rate">取樣率</string>
<string name="seek_bar_steps">進度條跳轉值</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>

View file

@ -461,6 +461,7 @@
<string name="upcoming_tv">Upcoming TV Shows</string>
<string name="request_4k">Request in 4K</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">
<item>Disabled</item>
@ -532,8 +533,15 @@
</string-array>
<string-array name="player_backend_options">
<item>ExoPlayer (default)</item>
<item>MPV (Experimental)</item>
<item>ExoPlayer</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 name="aspect_ratios">

View file

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

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

View file

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