mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Merge branch 'main' into fea/libass-android-support
This commit is contained in:
commit
0b91d5add7
73 changed files with 3969 additions and 1458 deletions
13
.github/pull_request_template.md
vendored
13
.github/pull_request_template.md
vendored
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
Wholphin is an open-source Android TV client for Jellyfin. It aims to provide a different app UI that's inspired by Plex for users interested in migrating to Jellyfin.
|
||||
|
||||
This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin `v0.3.0+` supports playing media using either ExoPlayer/Media3 or MPV (experimental).
|
||||
This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin supports playing media using either ExoPlayer or MPV.
|
||||
|
||||
<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.
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,11 @@ class NavigationManager
|
|||
while (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
}
|
||||
if (backStack.isEmpty()) {
|
||||
backStack.add(0, destination)
|
||||
} else {
|
||||
backStack[0] = destination
|
||||
}
|
||||
log()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ fun DestinationContent(
|
|||
}
|
||||
|
||||
SeerrItemType.PERSON -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
DiscoverPersonPage(
|
||||
person = destination.item,
|
||||
modifier = modifier,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,3 +23,9 @@ data class SimpleMediaStream(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SimpleVideoStream(
|
||||
val index: Int,
|
||||
val hdr: Boolean,
|
||||
val is4k: Boolean,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ enum class PreferenceScreenOption {
|
|||
ADVANCED,
|
||||
USER_INTERFACE,
|
||||
SUBTITLES,
|
||||
EXO_PLAYER,
|
||||
MPV,
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ enum MediaExtensionStatus{
|
|||
enum PlayerBackend{
|
||||
EXO_PLAYER = 0;
|
||||
MPV = 1;
|
||||
PREFER_MPV = 2;
|
||||
}
|
||||
|
||||
message MpvOptions{
|
||||
|
|
|
|||
456
app/src/main/res/values-cs/strings.xml
Normal file
456
app/src/main/res/values-cs/strings.xml
Normal 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>
|
||||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">Kø</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
456
app/src/main/res/values-uk/strings.xml
Normal file
456
app/src/main/res/values-uk/strings.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue