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
64caa99fff
132 changed files with 5400 additions and 2951 deletions
82
.github/actions/native-build/action.yml
vendored
82
.github/actions/native-build/action.yml
vendored
|
|
@ -1,82 +0,0 @@
|
|||
name: "Native build"
|
||||
description: "Performs native builds"
|
||||
inputs:
|
||||
cache:
|
||||
description: "Whether to use the cache or not"
|
||||
required: true
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Load ffmpeg module cache
|
||||
id: cache_ffmpeg_module
|
||||
if: ${{ inputs.cache }}
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
app/libs
|
||||
key: ${{ runner.os }}-ffmpeg-${{ hashFiles('**/libs.versions.toml', '**/scripts/ffmpeg/build_ffmpeg_decoder.sh') }}
|
||||
- name: Load libmpv module cache
|
||||
id: cache_libmpv_module
|
||||
if: ${{ inputs.cache }}
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
app/src/main/libs
|
||||
key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }}
|
||||
- name: Install dependencies
|
||||
if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' || steps.cache_libmpv_module.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install jsonschema jinja2
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson nasm
|
||||
|
||||
|
||||
# ffmpeg
|
||||
- name: Build ffmpeg decoder
|
||||
id: ffmpeg-decoder
|
||||
if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
cd scripts/ffmpeg
|
||||
./build_ffmpeg_decoder.sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}"
|
||||
- name: Save ffmpeg module cache
|
||||
id: cache_ffmpeg_module_save
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
app/libs
|
||||
key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }}
|
||||
|
||||
# libmpv
|
||||
- name: Get libmpv dependencies
|
||||
if: steps.cache_libmpv_module.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
cd scripts/mpv
|
||||
./get_dependencies.sh
|
||||
- name: Build libmpv dependencies
|
||||
if: steps.cache_libmpv_module.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
cd scripts/mpv
|
||||
./buildall.sh --clean --arch arm64 mpv
|
||||
./buildall.sh mpv
|
||||
cd ../..
|
||||
env PREFIX32="$(realpath scripts/mpv/prefix/armv7l)" PREFIX64="$(realpath scripts/mpv/prefix/arm64)" ndk-build -C app/src/main -j
|
||||
|
||||
- name: Setup jniLibs
|
||||
shell: bash
|
||||
run: |
|
||||
cd app/src/main
|
||||
mkdir -p jniLibs
|
||||
cp -r libs/* jniLibs
|
||||
#ln -s libs jniLibs
|
||||
- name: Save libmpv module cache
|
||||
id: cache_libmpv_module_save
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
app/src/main/libs
|
||||
key: ${{ steps.cache_libmpv_module.outputs.cache-primary-key }}
|
||||
15
.github/workflows/main.yml
vendored
15
.github/workflows/main.yml
vendored
|
|
@ -30,10 +30,6 @@ jobs:
|
|||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
- name: Native build
|
||||
uses: ./.github/actions/native-build
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Get version names
|
||||
run: |
|
||||
|
|
@ -55,8 +51,11 @@ jobs:
|
|||
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
|
||||
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
|
||||
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
||||
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
|
||||
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
|
||||
run: |
|
||||
./gradlew clean assembleRelease assembleDebug --no-daemon
|
||||
./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon
|
||||
|
||||
- name: Verify signatures
|
||||
run: |
|
||||
echo "Verify APK signatures"
|
||||
|
|
@ -68,7 +67,7 @@ jobs:
|
|||
find app/build/outputs/apk -name '*.apk' -print0 |
|
||||
while IFS= read -r -d '' line; do
|
||||
# Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
|
||||
short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')"
|
||||
short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//' | sed -E 's/-default//')"
|
||||
echo "$line => $short_name"
|
||||
cp "$line" "$short_name"
|
||||
done
|
||||
|
|
@ -82,6 +81,8 @@ jobs:
|
|||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
shopt -s globstar
|
||||
|
||||
gh release delete "${{ env.TAG_NAME }}" --cleanup-tag -y || true
|
||||
|
||||
|
||||
|
|
@ -98,4 +99,4 @@ jobs:
|
|||
|
||||
gh release create "${{ env.TAG_NAME }}" \
|
||||
--latest=false --prerelease --title "${{ env.VERSION_NAME }}" --target "${{ env.BRANCH_NAME }}" -F ci_release_note.txt \
|
||||
"app/build/outputs/apk/**/*.apk"
|
||||
app/build/outputs/apk/**/*.apk
|
||||
|
|
|
|||
19
.github/workflows/pr.yml
vendored
19
.github/workflows/pr.yml
vendored
|
|
@ -36,26 +36,9 @@ jobs:
|
|||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
- name: Native build
|
||||
uses: ./.github/actions/native-build
|
||||
with:
|
||||
cache: true
|
||||
- name: Build app
|
||||
id: buildapp
|
||||
run: |
|
||||
./gradlew clean assembleDebug testDebugUnitTest --no-daemon
|
||||
./gradlew clean assembleDefaultDebug testDefaultDebugUnitTest --no-daemon
|
||||
apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//')
|
||||
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
||||
|
||||
test-patch:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre-commit
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Test applying patch
|
||||
run: |
|
||||
git apply app/src/patches/play_store.patch
|
||||
git diff
|
||||
|
|
|
|||
31
.github/workflows/release.yml
vendored
31
.github/workflows/release.yml
vendored
|
|
@ -26,10 +26,6 @@ jobs:
|
|||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
- name: Native build
|
||||
uses: ./.github/actions/native-build
|
||||
with:
|
||||
cache: false
|
||||
- name: Build app
|
||||
id: buildapp
|
||||
env:
|
||||
|
|
@ -37,21 +33,11 @@ jobs:
|
|||
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
|
||||
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
|
||||
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
||||
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
|
||||
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
|
||||
run: |
|
||||
./gradlew clean assembleRelease --no-daemon
|
||||
./gradlew clean assembleDefaultRelease bundleAppstoreRelease --no-daemon
|
||||
|
||||
- name: Build app
|
||||
id: buildaab
|
||||
env:
|
||||
KEY_ALIAS: "${{ secrets.KEY_ALIAS }}"
|
||||
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
|
||||
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
|
||||
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
||||
run: |
|
||||
git apply app/src/patches/play_store.patch
|
||||
./gradlew bundleRelease --no-daemon
|
||||
aab=$(find app/build/outputs -name '*.aab')
|
||||
echo "aab=$aab" >> "$GITHUB_OUTPUT"
|
||||
- name: Verify signatures
|
||||
run: |
|
||||
echo "Verify APK signatures"
|
||||
|
|
@ -62,13 +48,16 @@ jobs:
|
|||
run: |
|
||||
find app/build/outputs/apk -name '*.apk' -print0 |
|
||||
while IFS= read -r -d '' line; do
|
||||
# Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
|
||||
short_name="$(echo $line | sed -E 's/-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')"
|
||||
# Wholphin-default-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
|
||||
short_name="$(echo $line | sed -E 's/-default-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')"
|
||||
echo "$line => $short_name"
|
||||
cp "$line" "$short_name"
|
||||
done
|
||||
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
|
||||
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
||||
|
||||
aab=$(find app/build/outputs -name '*.aab')
|
||||
echo "aab=$aab" >> "$GITHUB_OUTPUT"
|
||||
- name: Checksums
|
||||
run: |
|
||||
echo "SHA256 checksums:"
|
||||
|
|
@ -84,6 +73,8 @@ jobs:
|
|||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
shopt -s globstar
|
||||
|
||||
gh release create "${{ env.TAG_NAME }}" \
|
||||
--latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \
|
||||
"app/build/outputs/apk/**/*.apk"
|
||||
app/build/outputs/apk/**/*.apk
|
||||
|
|
|
|||
|
|
@ -43,21 +43,11 @@ Code is split into several packages:
|
|||
- `ui` - User interface code and ViewModels
|
||||
- `util` - Utility classes and functions
|
||||
|
||||
### Native components
|
||||
## Extensions
|
||||
|
||||
#### FFmpeg decoder module
|
||||
Wholphin uses several native components for extra playback compatibility. This includes Media3 ffmpeg/av1 decoders and `libmpv`. These extensions are not required to build the app, but without them some functionality will not work.
|
||||
|
||||
Wholphin ships with [media3 ffmpeg decoder module](https://github.com/androidx/media/blob/release/libraries/decoder_ffmpeg/README.md).
|
||||
|
||||
It is not required to build the extension in order to build the app locally.
|
||||
|
||||
You can build the module on MacOS or Linux with the [`build_ffmpeg_decoder.sh`](./scripts/ffmpeg/build_ffmpeg_decoder.sh) script.
|
||||
|
||||
#### MPV player backend
|
||||
|
||||
Wholphin has a playback engine that uses [`libmpv`](https://github.com/mpv-player/mpv). The app uses JNI code from [`mpv-android`](https://github.com/mpv-android/mpv-android) and has an implementation of `androidx.media3.common.Player` to swap out for `ExoPlayer`.
|
||||
|
||||
See the [build scripts](scripts/mpv/) for details on building this component.
|
||||
If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository.
|
||||
|
||||
### App settings
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else
|
|||
val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
|
||||
val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
|
||||
val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists()
|
||||
val mpvModuleExists = project.file("libs/wholphin-mpv-release.aar").exists()
|
||||
val extensionsRepoActive = project.hasProperty("WholphinExtensionsUsername")
|
||||
|
||||
val gitTags =
|
||||
providers
|
||||
|
|
@ -45,6 +47,8 @@ android {
|
|||
versionCode = gitTags.trim().lines().size
|
||||
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
|
||||
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
||||
|
||||
buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString())
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
|
@ -106,6 +110,20 @@ android {
|
|||
}
|
||||
}
|
||||
}
|
||||
flavorDimensions += "version"
|
||||
productFlavors {
|
||||
create("default") {
|
||||
dimension = "version"
|
||||
isDefault = true
|
||||
manifestPlaceholders += mapOf("leanback" to false)
|
||||
buildConfigField("boolean", "UPDATING_ENABLED", "true")
|
||||
}
|
||||
create("appstore") {
|
||||
dimension = "version"
|
||||
manifestPlaceholders += mapOf("leanback" to true)
|
||||
buildConfigField("boolean", "UPDATING_ENABLED", "false")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
|
|
@ -289,11 +307,33 @@ dependencies {
|
|||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||
if (ffmpegModuleExists || isCI) {
|
||||
|
||||
if (ffmpegModuleExists) {
|
||||
logger.info("Using local ffmpeg decoder")
|
||||
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
|
||||
} else if (extensionsRepoActive) {
|
||||
logger.info("Using prebuilt ffmpeg decoder")
|
||||
implementation(libs.wholphin.extensions.ffmpeg)
|
||||
} else {
|
||||
logger.warn("Media3 ffmpeg decoder was NOT found")
|
||||
}
|
||||
if (av1ModuleExists || isCI) {
|
||||
if (av1ModuleExists) {
|
||||
logger.info("Using local av1 decoder")
|
||||
implementation(files("libs/lib-decoder-av1-release.aar"))
|
||||
} else if (extensionsRepoActive) {
|
||||
logger.info("Using prebuilt av1 decoder")
|
||||
implementation(libs.wholphin.extensions.av1)
|
||||
} else {
|
||||
logger.warn("Media3 av1 decoder was NOT found")
|
||||
}
|
||||
if (mpvModuleExists) {
|
||||
logger.info("Using local libMPV build")
|
||||
implementation(files("libs/wholphin-mpv-release.aar"))
|
||||
} else if (extensionsRepoActive) {
|
||||
logger.info("Using prebuilt libMPV")
|
||||
implementation(libs.wholphin.extensions.mpv)
|
||||
} else {
|
||||
logger.warn("libMPV was NOT found")
|
||||
}
|
||||
|
||||
testImplementation(libs.mockk.android)
|
||||
|
|
|
|||
7
app/src/appstore/AndroidManifest.xml
Normal file
7
app/src/appstore/AndroidManifest.xml
Normal file
|
|
@ -0,0 +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">
|
||||
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove" />
|
||||
|
||||
</manifest>
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.microphone"
|
||||
android:required="false" />
|
||||
|
||||
<!-- Required for Android 11+ to query voice recognition availability -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Wholphin"
|
||||
android:name=".WholphinApplication"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".test.TestActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove" />
|
||||
|
||||
<!-- <service-->
|
||||
<!-- android:name=".WholphinDreamService"-->
|
||||
<!-- android:exported="true"-->
|
||||
<!-- android:label="@string/app_name"-->
|
||||
<!-- android:permission="android.permission.BIND_DREAM_SERVICE">-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="android.service.dreams.DreamService" />-->
|
||||
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||
<!-- </intent-filter>-->
|
||||
<!-- </service>-->
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
android:required="${leanback}" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.microphone"
|
||||
android:required="false" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
|
|
@ -34,6 +35,7 @@ import com.github.damontecres.wholphin.services.BackdropService
|
|||
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
||||
import com.github.damontecres.wholphin.services.DeviceProfileService
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||
|
|
@ -51,14 +53,15 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
|||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
|
|
@ -113,6 +116,9 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
|
||||
|
||||
@Inject
|
||||
lateinit var latestNextUpSchedulerService: LatestNextUpSchedulerService
|
||||
|
||||
@Inject
|
||||
lateinit var backdropService: BackdropService
|
||||
|
||||
|
|
@ -394,6 +400,7 @@ class MainActivity : AppCompatActivity() {
|
|||
class MainActivityViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val preferences: DataStore<AppPreferences>,
|
||||
val serverRepository: ServerRepository,
|
||||
private val navigationManager: SetupNavigationManager,
|
||||
|
|
@ -402,9 +409,19 @@ class MainActivityViewModel
|
|||
private val appUpgradeHandler: AppUpgradeHandler,
|
||||
) : ViewModel() {
|
||||
fun appStart() {
|
||||
viewModelScope.launchIO {
|
||||
viewModelScope.launchDefault {
|
||||
try {
|
||||
val needUpgrade = appUpgradeHandler.needUpgrade()
|
||||
if (needUpgrade) {
|
||||
showToast(
|
||||
context,
|
||||
context.getString(
|
||||
R.string.updated_toast,
|
||||
appUpgradeHandler.currentVersion.toString(),
|
||||
),
|
||||
)
|
||||
appUpgradeHandler.run()
|
||||
}
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
val prefs =
|
||||
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
|
|
@ -447,7 +464,7 @@ class MainActivityViewModel
|
|||
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
viewModelScope.launchDefault {
|
||||
// Create the mediaCodecCapabilitiesTest if needed
|
||||
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,19 @@ val DefaultFilterOptions =
|
|||
DecadeFilter,
|
||||
)
|
||||
|
||||
val DefaultTvFilterOptions =
|
||||
listOf(
|
||||
PlayedFilter,
|
||||
FavoriteFilter,
|
||||
GenreFilter,
|
||||
StudioFilter,
|
||||
CommunityRatingFilter,
|
||||
OfficialRatingFilter,
|
||||
VideoTypeFilter,
|
||||
YearFilter,
|
||||
DecadeFilter,
|
||||
)
|
||||
|
||||
val DefaultForFavoritesFilterOptions =
|
||||
listOf(
|
||||
PlayedFilter,
|
||||
|
|
@ -32,6 +45,19 @@ val DefaultForGenresFilterOptions =
|
|||
listOf(
|
||||
PlayedFilter,
|
||||
FavoriteFilter,
|
||||
StudioFilter,
|
||||
CommunityRatingFilter,
|
||||
OfficialRatingFilter,
|
||||
VideoTypeFilter,
|
||||
YearFilter,
|
||||
DecadeFilter,
|
||||
)
|
||||
|
||||
val DefaultForStudiosFilterOptions =
|
||||
listOf(
|
||||
PlayedFilter,
|
||||
FavoriteFilter,
|
||||
GenreFilter,
|
||||
CommunityRatingFilter,
|
||||
OfficialRatingFilter,
|
||||
VideoTypeFilter,
|
||||
|
|
@ -183,3 +209,16 @@ data object CommunityRatingFilter : ItemFilterBy<Int> {
|
|||
filter: GetItemsFilter,
|
||||
): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble())
|
||||
}
|
||||
|
||||
data object StudioFilter : ItemFilterBy<List<UUID>> {
|
||||
override val stringRes: Int = R.string.studios
|
||||
|
||||
override val supportMultiple: Boolean = true
|
||||
|
||||
override fun get(filter: GetItemsFilter): List<UUID>? = filter.studios
|
||||
|
||||
override fun set(
|
||||
value: List<UUID>?,
|
||||
filter: GetItemsFilter,
|
||||
): GetItemsFilter = filter.copy(studios = value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,8 +79,7 @@ data class BaseItem(
|
|||
|
||||
val canDelete: Boolean get() = data.canDelete == true
|
||||
|
||||
@Transient
|
||||
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
|
||||
val aspectRatio: Float? get() = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
|
||||
|
||||
val indexNumber get() = data.indexNumber
|
||||
|
||||
|
|
@ -92,8 +91,7 @@ data class BaseItem(
|
|||
|
||||
val favorite get() = data.userData?.isFavorite ?: false
|
||||
|
||||
@Transient
|
||||
val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks
|
||||
val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks
|
||||
|
||||
/**
|
||||
* Contains pre computed UI elements that would be expensive to create on the main thread
|
||||
|
|
@ -270,6 +268,7 @@ fun createGenreDestination(
|
|||
includeItemTypes: List<BaseItemKind>?,
|
||||
) = Destination.FilteredCollection(
|
||||
itemId = parentId,
|
||||
parentType = BaseItemKind.GENRE,
|
||||
filter =
|
||||
CollectionFolderFilter(
|
||||
nameOverride =
|
||||
|
|
@ -286,3 +285,31 @@ fun createGenreDestination(
|
|||
),
|
||||
recursive = true,
|
||||
)
|
||||
|
||||
fun createStudioDestination(
|
||||
studioId: UUID,
|
||||
name: String,
|
||||
parentId: UUID,
|
||||
parentName: String?,
|
||||
includeItemTypes: List<BaseItemKind>?,
|
||||
) = Destination.FilteredCollection(
|
||||
itemId = parentId,
|
||||
parentType = BaseItemKind.STUDIO,
|
||||
filter =
|
||||
CollectionFolderFilter(
|
||||
nameOverride =
|
||||
listOfNotNull(
|
||||
name,
|
||||
parentName,
|
||||
).joinToString(" "),
|
||||
filter =
|
||||
GetItemsFilter(
|
||||
studios = listOf(studioId),
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
useSavedLibraryDisplayInfo = false,
|
||||
),
|
||||
recursive = true,
|
||||
)
|
||||
|
||||
val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty()
|
||||
|
|
|
|||
|
|
@ -90,6 +90,18 @@ sealed interface HomeRowConfig {
|
|||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Row of a studios in a library
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("Studios")
|
||||
data class Studios(
|
||||
val parentId: UUID,
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault,
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Studios = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Favorites for a specific type
|
||||
*/
|
||||
|
|
@ -162,7 +174,7 @@ sealed interface HomeRowConfig {
|
|||
@SerialName("ByParent")
|
||||
data class ByParent(
|
||||
val parentId: UUID,
|
||||
val recursive: Boolean,
|
||||
val recursive: Boolean = false,
|
||||
val sort: SortAndDirection? = null,
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.Build
|
||||
import androidx.core.content.edit
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -43,12 +43,10 @@ class AppUpgradeHandler
|
|||
private val appPreferences: DataStore<AppPreferences>,
|
||||
private val seerrServerDao: SeerrServerDao,
|
||||
) {
|
||||
suspend fun run() {
|
||||
val pkgInfo =
|
||||
WholphinApplication.instance.packageManager.getPackageInfo(
|
||||
WholphinApplication.instance.packageName,
|
||||
0,
|
||||
)
|
||||
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
|
||||
|
||||
fun needUpgrade(): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
|
||||
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
|
||||
|
|
@ -60,10 +58,25 @@ class AppUpgradeHandler
|
|||
} else {
|
||||
pkgInfo.versionCode.toLong()
|
||||
}
|
||||
if (newVersion != previousVersion || newVersionCode != previousVersionCode) {
|
||||
Timber.i(
|
||||
"App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
|
||||
"App versions: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
|
||||
)
|
||||
return newVersion != previousVersion || newVersionCode != previousVersionCode
|
||||
}
|
||||
|
||||
suspend fun run() {
|
||||
Timber.i("App upgrade started")
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
|
||||
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
|
||||
|
||||
val newVersion = pkgInfo.versionName!!
|
||||
val newVersionCode =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
pkgInfo.longVersionCode
|
||||
} else {
|
||||
pkgInfo.versionCode.toLong()
|
||||
}
|
||||
// Store the previous and new version info
|
||||
prefs.edit(true) {
|
||||
putString(VERSION_NAME_PREVIOUS_KEY, previousVersion)
|
||||
|
|
@ -82,9 +95,6 @@ class AppUpgradeHandler
|
|||
Timber.e(ex, "Exception during app upgrade")
|
||||
}
|
||||
Timber.i("App upgrade complete")
|
||||
} else {
|
||||
Timber.d("No app update needed")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DisplayPreferencesService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun getDisplayPreferences(
|
||||
userId: UUID,
|
||||
displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID,
|
||||
client: String = DEFAULT_CLIENT,
|
||||
) = api.displayPreferencesApi
|
||||
.getDisplayPreferences(
|
||||
userId = userId,
|
||||
displayPreferencesId = displayPreferencesId,
|
||||
client = client,
|
||||
).content
|
||||
|
||||
suspend fun updateDisplayPreferences(
|
||||
userId: UUID,
|
||||
displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID,
|
||||
client: String = DEFAULT_CLIENT,
|
||||
block: MutableMap<String, String?>.() -> Unit,
|
||||
) {
|
||||
mutex.withLock {
|
||||
val current = getDisplayPreferences(userId, DEFAULT_DISPLAY_PREF_ID)
|
||||
val customPrefs =
|
||||
current.customPrefs.toMutableMap().apply {
|
||||
block.invoke(this)
|
||||
}
|
||||
api.displayPreferencesApi.updateDisplayPreferences(
|
||||
displayPreferencesId = displayPreferencesId,
|
||||
userId = userId,
|
||||
client = client,
|
||||
data = current.copy(customPrefs = customPrefs),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_DISPLAY_PREF_ID = "default"
|
||||
val DEFAULT_CLIENT = if (BuildConfig.DEBUG) "Wholphin (Debug)" else "Wholphin"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
|
|
@ -39,4 +40,7 @@ class FavoriteWatchManager
|
|||
} else {
|
||||
api.userLibraryApi.unmarkFavoriteItem(itemId).content
|
||||
}
|
||||
|
||||
suspend fun removeContinueWatching(item: BaseItem) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.model.HomePageSettings
|
|||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
||||
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
||||
import com.github.damontecres.wholphin.data.model.createStudioDestination
|
||||
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
||||
import com.github.damontecres.wholphin.preferences.HomePagePreferences
|
||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||
|
|
@ -21,6 +22,7 @@ import com.github.damontecres.wholphin.ui.toServerString
|
|||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
|
||||
|
|
@ -40,7 +42,6 @@ import kotlinx.serialization.json.jsonArray
|
|||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
|
|
@ -58,6 +59,7 @@ import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
|||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
|
@ -78,6 +80,7 @@ class HomeSettingsService
|
|||
private val latestNextUpService: LatestNextUpService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val suggestionService: SuggestionService,
|
||||
private val displayPreferencesService: DisplayPreferencesService,
|
||||
) {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val jsonParser =
|
||||
|
|
@ -100,19 +103,11 @@ class HomeSettingsService
|
|||
suspend fun saveToServer(
|
||||
userId: UUID,
|
||||
settings: HomePageSettings,
|
||||
displayPreferencesId: String = DISPLAY_PREF_ID,
|
||||
displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
|
||||
) {
|
||||
val current = getDisplayPreferences(userId, DISPLAY_PREF_ID)
|
||||
val customPrefs =
|
||||
current.customPrefs.toMutableMap().apply {
|
||||
displayPreferencesService.updateDisplayPreferences(userId, displayPreferencesId) {
|
||||
put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings))
|
||||
}
|
||||
api.displayPreferencesApi.updateDisplayPreferences(
|
||||
displayPreferencesId = displayPreferencesId,
|
||||
userId = userId,
|
||||
client = context.getString(R.string.app_name),
|
||||
data = current.copy(customPrefs = customPrefs),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -124,24 +119,15 @@ class HomeSettingsService
|
|||
*/
|
||||
suspend fun loadFromServer(
|
||||
userId: UUID,
|
||||
displayPreferencesId: String = DISPLAY_PREF_ID,
|
||||
): HomePageSettings? {
|
||||
val current = getDisplayPreferences(userId, displayPreferencesId)
|
||||
return current.customPrefs[CUSTOM_PREF_ID]?.let {
|
||||
displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
|
||||
): HomePageSettings? =
|
||||
displayPreferencesService
|
||||
.getDisplayPreferences(userId, displayPreferencesId)
|
||||
.customPrefs[CUSTOM_PREF_ID]
|
||||
?.let {
|
||||
val jsonElement = jsonParser.parseToJsonElement(it)
|
||||
decode(jsonElement)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getDisplayPreferences(
|
||||
userId: UUID,
|
||||
displayPreferencesId: String,
|
||||
) = api.displayPreferencesApi
|
||||
.getDisplayPreferences(
|
||||
userId = userId,
|
||||
displayPreferencesId = displayPreferencesId,
|
||||
client = context.getString(R.string.app_name),
|
||||
).content
|
||||
|
||||
/**
|
||||
* Computes the filename for locally saved [HomePageSettings]
|
||||
|
|
@ -331,12 +317,12 @@ class HomeSettingsService
|
|||
*/
|
||||
suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
|
||||
val customPrefs =
|
||||
api.displayPreferencesApi
|
||||
displayPreferencesService
|
||||
.getDisplayPreferences(
|
||||
displayPreferencesId = "usersettings",
|
||||
userId = userId,
|
||||
client = "emby",
|
||||
).content.customPrefs
|
||||
).customPrefs
|
||||
val userDto by api.userApi.getUserById(userId)
|
||||
val config = userDto.configuration ?: DefaultUserConfiguration
|
||||
val libraries =
|
||||
|
|
@ -483,6 +469,15 @@ class HomeSettingsService
|
|||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.Studios -> {
|
||||
val name = getItemName(config.parentId) ?: ""
|
||||
HomeRowConfigDisplay(
|
||||
id,
|
||||
context.getString(R.string.studios_in, name),
|
||||
config,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.GetItems -> {
|
||||
HomeRowConfigDisplay(id, config.name, config)
|
||||
}
|
||||
|
|
@ -592,6 +587,7 @@ class HomeSettingsService
|
|||
title = context.getString(R.string.continue_watching),
|
||||
items = resume,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -610,6 +606,7 @@ class HomeSettingsService
|
|||
title = context.getString(R.string.next_up),
|
||||
items = nextUp,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -639,6 +636,7 @@ class HomeSettingsService
|
|||
nextUp,
|
||||
),
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -698,6 +696,58 @@ class HomeSettingsService
|
|||
title,
|
||||
genres,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.Studios -> {
|
||||
val request =
|
||||
GetStudiosRequest(
|
||||
parentId = row.parentId,
|
||||
userId = userDto.id,
|
||||
limit = limit,
|
||||
includeItemTypes = listOf(BaseItemKind.SERIES),
|
||||
)
|
||||
val items =
|
||||
GetStudiosRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
val library =
|
||||
libraries
|
||||
.firstOrNull { it.itemId == row.parentId }
|
||||
val title =
|
||||
library?.name?.let { context.getString(R.string.studios_in, it) }
|
||||
?: context.getString(R.string.studios)
|
||||
val studios =
|
||||
items.map {
|
||||
val imageUrl =
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = it.id,
|
||||
imageType = ImageType.THUMB,
|
||||
)
|
||||
BaseItem(
|
||||
it,
|
||||
false,
|
||||
imageUrl,
|
||||
createStudioDestination(
|
||||
studioId = it.id,
|
||||
name = it.name ?: "",
|
||||
parentId = row.parentId,
|
||||
parentName = library?.name,
|
||||
includeItemTypes =
|
||||
library?.collectionType?.let {
|
||||
getTypeFor(it)?.let {
|
||||
listOf(it)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Success(
|
||||
title,
|
||||
studios,
|
||||
viewOptions = row.viewOptions,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -725,6 +775,7 @@ class HomeSettingsService
|
|||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
latest
|
||||
|
|
@ -757,6 +808,7 @@ class HomeSettingsService
|
|||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -785,6 +837,7 @@ class HomeSettingsService
|
|||
name ?: context.getString(R.string.collection),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -812,6 +865,7 @@ class HomeSettingsService
|
|||
row.name,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -862,6 +916,7 @@ class HomeSettingsService
|
|||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -886,6 +941,7 @@ class HomeSettingsService
|
|||
context.getString(R.string.active_recordings),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -910,6 +966,7 @@ class HomeSettingsService
|
|||
context.getString(R.string.live_tv),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -927,6 +984,7 @@ class HomeSettingsService
|
|||
context.getString(R.string.channels),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -949,12 +1007,14 @@ class HomeSettingsService
|
|||
title,
|
||||
suggestions.items,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
} else if (suggestions is SuggestionsResource.Empty) {
|
||||
Success(
|
||||
title,
|
||||
listOf(),
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
} else {
|
||||
Error(
|
||||
|
|
@ -966,7 +1026,6 @@ class HomeSettingsService
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val DISPLAY_PREF_ID = "default"
|
||||
const val CUSTOM_PREF_ID = "home_settings"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
@file:UseSerializers(
|
||||
UUIDSerializer::class,
|
||||
LocalDateTimeSerializer::class,
|
||||
)
|
||||
|
||||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
|
||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
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.GetNextUpRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
|
@ -31,9 +41,10 @@ import kotlin.time.Duration.Companion.milliseconds
|
|||
class LatestNextUpService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val datePlayedService: DatePlayedService,
|
||||
private val displayPreferencesService: DisplayPreferencesService,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
) {
|
||||
/**
|
||||
* Get resume (continue watching) items for a user
|
||||
|
|
@ -80,6 +91,7 @@ class LatestNextUpService
|
|||
maxDays: Int,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
): List<BaseItem> {
|
||||
val removedSeries = getRemovedFromNextUp(userId)
|
||||
val nextUpDateCutoff =
|
||||
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
|
||||
val request =
|
||||
|
|
@ -100,6 +112,23 @@ class LatestNextUpService
|
|||
.content
|
||||
.items
|
||||
.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||
.filter {
|
||||
val seriesId = it.data.seriesId
|
||||
if (seriesId != null && seriesId in removedSeries) {
|
||||
// User has previously removed the series
|
||||
val lastPlayedDate = it.data.userData?.lastPlayedDate
|
||||
if (lastPlayedDate != null) {
|
||||
// If item played it after it was removed, should include it
|
||||
lastPlayedDate > removedSeries[seriesId]
|
||||
} else {
|
||||
// If unknown last played, filter out
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
return nextUp
|
||||
}
|
||||
|
||||
|
|
@ -140,4 +169,112 @@ class LatestNextUpService
|
|||
Timber.v("buildCombined took %s", duration)
|
||||
return@withContext result
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a series from next up
|
||||
*/
|
||||
suspend fun removeFromNextUp(
|
||||
userId: UUID,
|
||||
episode: BaseItem,
|
||||
) {
|
||||
favoriteWatchManager.setWatched(episode.id, false)
|
||||
episode.data.seriesId?.let { seriesId ->
|
||||
displayPreferencesService.updateDisplayPreferences(userId) {
|
||||
val removedIds =
|
||||
get(REMOVED_KEY)
|
||||
?.let {
|
||||
Json.decodeFromString<RemovedSeriesIds>(it).value
|
||||
}.orEmpty()
|
||||
.toMutableMap()
|
||||
removedIds[seriesId] = LocalDateTime.now()
|
||||
put(
|
||||
REMOVED_KEY,
|
||||
Json.encodeToString(RemovedSeriesIds(removedIds)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get when series were removed from next up
|
||||
*/
|
||||
suspend fun getRemovedFromNextUp(userId: UUID): Map<UUID, LocalDateTime> =
|
||||
displayPreferencesService
|
||||
.getDisplayPreferences(userId)
|
||||
.customPrefs[REMOVED_KEY]
|
||||
?.let {
|
||||
Json.decodeFromString<RemovedSeriesIds>(it).value
|
||||
}.orEmpty()
|
||||
|
||||
suspend fun allowSeriesRemovedFromNextUp(
|
||||
userId: UUID,
|
||||
seriesId: UUID,
|
||||
) {
|
||||
displayPreferencesService.updateDisplayPreferences(userId) {
|
||||
val ids =
|
||||
get(REMOVED_KEY)
|
||||
?.let {
|
||||
Json.decodeFromString<RemovedSeriesIds>(it).value
|
||||
}.orEmpty()
|
||||
.toMutableMap()
|
||||
ids.remove(seriesId)
|
||||
put(
|
||||
REMOVED_KEY,
|
||||
Json.encodeToString(RemovedSeriesIds(ids)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has watched a series since removing it
|
||||
*/
|
||||
suspend fun updateRemovedFromNextUp(userId: UUID) {
|
||||
val removed = getRemovedFromNextUp(userId)
|
||||
val newRemoved = removed.toMutableMap()
|
||||
var changed = false
|
||||
removed.forEach { (seriesId, timestamp) ->
|
||||
val item =
|
||||
api.itemsApi
|
||||
.getItems(
|
||||
userId = userId,
|
||||
parentId = seriesId,
|
||||
recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
||||
sortBy = listOf(ItemSortBy.DATE_PLAYED),
|
||||
sortOrder = listOf(SortOrder.DESCENDING),
|
||||
limit = 1,
|
||||
).content.items
|
||||
.firstOrNull()
|
||||
if (item != null) {
|
||||
val lastPlayed = item.userData?.lastPlayedDate
|
||||
if (lastPlayed != null && lastPlayed > timestamp) {
|
||||
Timber.v("Updating removed next up for series %s", seriesId)
|
||||
newRemoved.remove(seriesId)
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
// Series doesn't exist anymore
|
||||
Timber.v("Updating removed next up for missing series %s", seriesId)
|
||||
newRemoved.remove(seriesId)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
displayPreferencesService.updateDisplayPreferences(userId) {
|
||||
put(
|
||||
REMOVED_KEY,
|
||||
Json.encodeToString(RemovedSeriesIds(newRemoved)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REMOVED_KEY = "removeNextUp"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RemovedSeriesIds(
|
||||
val value: Map<UUID, LocalDateTime>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import dagger.hilt.android.scopes.ActivityScoped
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@HiltWorker
|
||||
class LatestNextUpWorker
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@Assisted private val context: Context,
|
||||
@Assisted workerParams: WorkerParameters,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val api: ApiClient,
|
||||
private val latestNextUpService: LatestNextUpService,
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
override suspend fun doWork(): Result {
|
||||
Timber.d("Start")
|
||||
val serverId =
|
||||
inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure()
|
||||
val userId =
|
||||
inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure()
|
||||
|
||||
try {
|
||||
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||
// Not active
|
||||
var currentUser = serverRepository.current.value
|
||||
if (currentUser == null) {
|
||||
serverRepository.restoreSession(serverId, userId)
|
||||
currentUser = serverRepository.current.value
|
||||
}
|
||||
if (currentUser == null) {
|
||||
Timber.w("No user found during run")
|
||||
return Result.failure()
|
||||
}
|
||||
}
|
||||
latestNextUpService.updateRemovedFromNextUp(userId)
|
||||
return Result.success()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during updateRemovedFromNextUp")
|
||||
return Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val WORK_NAME = "com.github.damontecres.wholphin.services.LatestNextUpWorker"
|
||||
const val PARAM_USER_ID = "userId"
|
||||
const val PARAM_SERVER_ID = "serverId"
|
||||
}
|
||||
}
|
||||
|
||||
@ActivityScoped
|
||||
class LatestNextUpSchedulerService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ActivityContext private val context: Context,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val workManager: WorkManager,
|
||||
) {
|
||||
private val activity =
|
||||
(context as? AppCompatActivity)
|
||||
?: throw IllegalStateException(
|
||||
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
|
||||
)
|
||||
|
||||
// Exposed for testing
|
||||
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
init {
|
||||
serverRepository.current.observe(activity) { user ->
|
||||
Timber.v("New user %s", user?.user?.id)
|
||||
if (user == null) {
|
||||
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
|
||||
} else {
|
||||
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
|
||||
scheduleWork(user.user.id, user.server.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun scheduleWork(
|
||||
userId: UUID,
|
||||
serverId: UUID,
|
||||
) {
|
||||
val constraints =
|
||||
Constraints
|
||||
.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
val periodicWorkRequestBuilder =
|
||||
PeriodicWorkRequestBuilder<LatestNextUpWorker>(
|
||||
repeatInterval = 4.hours.toJavaDuration(),
|
||||
).setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
15.minutes.toJavaDuration(),
|
||||
).setConstraints(constraints)
|
||||
.setInputData(
|
||||
workDataOf(
|
||||
LatestNextUpWorker.PARAM_USER_ID to userId.toString(),
|
||||
LatestNextUpWorker.PARAM_SERVER_ID to serverId.toString(),
|
||||
),
|
||||
)
|
||||
Timber.i("Scheduling periodic LatestNextUpWorker")
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
uniqueWorkName = LatestNextUpWorker.WORK_NAME,
|
||||
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE,
|
||||
request = periodicWorkRequestBuilder.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ import com.github.damontecres.wholphin.util.BlockingList
|
|||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.PlaybackItemState
|
||||
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
||||
import com.github.damontecres.wholphin.util.profile.Codec
|
||||
import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -83,6 +83,8 @@ class MusicService
|
|||
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
|
||||
val state: StateFlow<MusicServiceState> = _state
|
||||
|
||||
private val audioFormats by lazy { listOf(*supportedAudioCodecs) }
|
||||
|
||||
val player: Player by lazy {
|
||||
ExoPlayer
|
||||
.Builder(context)
|
||||
|
|
@ -286,14 +288,9 @@ class MusicService
|
|||
val url =
|
||||
api.universalAudioApi.getUniversalAudioStreamUrl(
|
||||
itemId = audio.id,
|
||||
container =
|
||||
listOf(
|
||||
Codec.Audio.OPUS,
|
||||
Codec.Audio.MP3,
|
||||
Codec.Audio.AAC,
|
||||
Codec.Audio.FLAC,
|
||||
),
|
||||
container = audioFormats,
|
||||
)
|
||||
Timber.i("url=%s", url)
|
||||
val imageUrl =
|
||||
audio.data.albumId?.let { albumId ->
|
||||
imageUrlService.getItemImageUrl(
|
||||
|
|
|
|||
|
|
@ -191,6 +191,18 @@ class SeerrService
|
|||
return "${base}${prefix}$path"
|
||||
}
|
||||
|
||||
suspend fun createDiscoverItem(item: Any): DiscoverItem =
|
||||
when (item) {
|
||||
is MovieResult -> createDiscoverItem(item)
|
||||
is MovieDetails -> createDiscoverItem(item)
|
||||
is TvResult -> createDiscoverItem(item)
|
||||
is TvDetails -> createDiscoverItem(item)
|
||||
is SeerrSearchResult -> createDiscoverItem(item)
|
||||
is CreditCast -> createDiscoverItem(item)
|
||||
is CreditCrew -> createDiscoverItem(item)
|
||||
else -> throw IllegalArgumentException("Unsupported type ${item::class.qualifiedName}")
|
||||
}
|
||||
|
||||
suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem =
|
||||
DiscoverItem(
|
||||
id = movie.id,
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class UpdateChecker
|
|||
|
||||
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
|
||||
|
||||
val ACTIVE = true
|
||||
const val ACTIVE = BuildConfig.UPDATING_ENABLED
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -128,18 +128,36 @@ class UpdateChecker
|
|||
return Version.fromString(pkgInfo.versionName!!)
|
||||
}
|
||||
|
||||
suspend fun getRelease(version: Version): Release? {
|
||||
val url =
|
||||
"https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}"
|
||||
return withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
getRelease(request)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest released version
|
||||
*/
|
||||
suspend fun getLatestRelease(updateUrl: String): Release? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
suspend fun getLatestRelease(updateUrl: String): Release? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(updateUrl)
|
||||
.get()
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use {
|
||||
getRelease(request)
|
||||
}
|
||||
|
||||
private fun getRelease(request: Request): Release? {
|
||||
return okHttpClient.newCall(request).execute().use {
|
||||
if (it.isSuccessful && it.body != null) {
|
||||
val result = Json.parseToJsonElement(it.body!!.string())
|
||||
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
||||
|
|
@ -161,19 +179,18 @@ class UpdateChecker
|
|||
m.groupValues[1]
|
||||
}.toList()
|
||||
} else {
|
||||
listOf()
|
||||
emptyList()
|
||||
}
|
||||
return@use Release(version, downloadUrl, publishedAt, body, notes)
|
||||
} else {
|
||||
Timber.w("Update version parsing failed. name=$name")
|
||||
}
|
||||
} else {
|
||||
Timber.w("Update check failed: ${it.message}")
|
||||
Timber.w("Update check failed ${it.code}: ${it.message}")
|
||||
}
|
||||
return@use null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and install an update
|
||||
|
|
@ -347,7 +364,19 @@ data class Release(
|
|||
val publishedAt: String?,
|
||||
val body: String?,
|
||||
val notes: List<String>,
|
||||
)
|
||||
) {
|
||||
val content =
|
||||
"# ${version}\n" +
|
||||
(
|
||||
(notes.joinToString("\n").takeIf { it.isNotNullOrBlank() } ?: "") +
|
||||
(body ?: "")
|
||||
).replace(
|
||||
Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"),
|
||||
"#$1",
|
||||
)
|
||||
// Remove the last line for full changelog since its just a link
|
||||
.replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "")
|
||||
}
|
||||
|
||||
interface DownloadCallback {
|
||||
fun contentLength(contentLength: Long)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -98,10 +99,15 @@ fun BannerCard(
|
|||
}
|
||||
}
|
||||
var imageError by remember(imageUrl) { mutableStateOf(false) }
|
||||
|
||||
// Stabilize callbacks to prevent AsyncImage from recomposing
|
||||
val currentOnClick by rememberUpdatedState(onClick)
|
||||
val currentOnLongClick by rememberUpdatedState(onLongClick)
|
||||
|
||||
Card(
|
||||
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onClick = { currentOnClick() },
|
||||
onLongClick = { currentOnLongClick() },
|
||||
interactionSource = interactionSource,
|
||||
colors =
|
||||
CardDefaults.colors(
|
||||
|
|
@ -119,7 +125,7 @@ fun BannerCard(
|
|||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = imageContentScale,
|
||||
onError = { imageError = true },
|
||||
onError = remember { { imageError = true } },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -34,8 +35,10 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.Icon
|
||||
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.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
|
|
@ -50,14 +53,14 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
|||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun DiscoverItemCard(
|
||||
item: DiscoverItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
showOverlay: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
showOverlay: Boolean = true,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
width: Dp = Cards.height2x3 * AspectRatios.TALL,
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||
|
|
@ -77,16 +80,15 @@ fun DiscoverItemCard(
|
|||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
val width = Cards.height2x3 * AspectRatios.TALL
|
||||
val height = Dp.Unspecified * (1f / AspectRatios.TALL)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
modifier = modifier.size(width, height),
|
||||
modifier = modifier.size(width, Dp.Unspecified),
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Dp.Unspecified, Cards.height2x3)
|
||||
.size(width, Dp.Unspecified)
|
||||
.aspectRatio(AspectRatios.TALL),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
|
|
@ -286,6 +288,88 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DiscoverViewMoreCard(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
||||
|
||||
val hideOverlayDelay = 500L
|
||||
if (focused) {
|
||||
LaunchedEffect(Unit) {
|
||||
delay(hideOverlayDelay)
|
||||
if (focused) {
|
||||
focusedAfterDelay = true
|
||||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
val width = Cards.height2x3 * AspectRatios.TALL
|
||||
val height = Dp.Unspecified * (1f / AspectRatios.TALL)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
modifier = modifier.size(width, height),
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Dp.Unspecified, Cards.height2x3)
|
||||
.aspectRatio(AspectRatios.TALL),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
colors =
|
||||
CardDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowForward,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
contentDescription = "View more",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.view_more),
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
|
|
@ -294,6 +378,11 @@ private fun Preview() {
|
|||
PendingIndicator()
|
||||
AvailableIndicator()
|
||||
PartiallyAvailableIndicator()
|
||||
DiscoverViewMoreCard(
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ fun EpisodeCard(
|
|||
modifier: Modifier = Modifier,
|
||||
imageHeight: Dp = Dp.Unspecified,
|
||||
imageWidth: Dp = Dp.Unspecified,
|
||||
showImageOverlay: Boolean = false,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val dto = item?.data
|
||||
|
|
@ -94,7 +95,7 @@ fun EpisodeCard(
|
|||
ItemCardImage(
|
||||
item = item,
|
||||
name = item?.name,
|
||||
showOverlay = false,
|
||||
showOverlay = showImageOverlay,
|
||||
favorite = dto?.userData?.isFavorite ?: false,
|
||||
watched = dto?.userData?.played ?: false,
|
||||
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ import androidx.compose.foundation.lazy.LazyRow
|
|||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
|
@ -45,6 +47,10 @@ fun <T> ItemRow(
|
|||
val firstFocus = remember { FocusRequester() }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var position by rememberInt()
|
||||
|
||||
val currentOnClickItem by rememberUpdatedState(onClickItem)
|
||||
val currentOnLongClickItem by rememberUpdatedState(onLongClickItem)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
|
|
@ -54,12 +60,8 @@ fun <T> ItemRow(
|
|||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
ItemRowTitle(title)
|
||||
|
||||
LazyRow(
|
||||
state = state,
|
||||
horizontalArrangement = Arrangement.spacedBy(horizontalPadding),
|
||||
|
|
@ -73,25 +75,50 @@ fun <T> ItemRow(
|
|||
) {
|
||||
itemsIndexed(items) { index, item ->
|
||||
val cardModifier =
|
||||
remember(index, position) {
|
||||
if (index == position) {
|
||||
Modifier.focusRequester(firstFocus)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
}
|
||||
|
||||
val onClick =
|
||||
remember(index, item) {
|
||||
{
|
||||
position = index
|
||||
if (item != null) currentOnClickItem(index, item)
|
||||
}
|
||||
}
|
||||
|
||||
val onLongClick =
|
||||
remember(index, item) {
|
||||
{
|
||||
position = index
|
||||
if (item != null) currentOnLongClickItem(index, item)
|
||||
}
|
||||
}
|
||||
|
||||
cardContent.invoke(
|
||||
index,
|
||||
item,
|
||||
cardModifier,
|
||||
{
|
||||
position = index
|
||||
if (item != null) onClickItem.invoke(index, item)
|
||||
},
|
||||
{
|
||||
position = index
|
||||
if (item != null) onLongClickItem.invoke(index, item)
|
||||
},
|
||||
onClick,
|
||||
onLongClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun ItemRowTitle(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) = Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = modifier.padding(start = 8.dp),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
|
|
@ -25,9 +26,11 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Border
|
||||
|
|
@ -80,7 +83,7 @@ fun PersonCard(
|
|||
) {
|
||||
val hideOverlayDelay = 1_000L
|
||||
|
||||
val focused = interactionSource.collectIsFocusedAsState().value
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
||||
|
||||
if (focused) {
|
||||
|
|
@ -95,10 +98,13 @@ fun PersonCard(
|
|||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||
|
||||
// Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
|
||||
val spaceBetweenState = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
|
||||
val spaceBelowState = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
|
||||
modifier = modifier,
|
||||
) {
|
||||
Card(
|
||||
|
|
@ -168,8 +174,16 @@ fun PersonCard(
|
|||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
// Optimization: move animation reads to layout/draw phase
|
||||
.offset {
|
||||
IntOffset(0, (spaceBetweenState.value - 4.dp).roundToPx())
|
||||
}.layout { measurable, constraints ->
|
||||
val paddingPx = spaceBelowState.value.roundToPx()
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height + paddingPx) {
|
||||
placeable.placeRelative(0, 0)
|
||||
}
|
||||
}.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = name ?: "",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -18,10 +19,12 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
|
|
@ -52,7 +55,7 @@ fun SeasonCard(
|
|||
val imageUrlService = LocalImageUrlService.current
|
||||
val density = LocalDensity.current
|
||||
val imageUrl =
|
||||
remember(item, imageHeight, imageWidth) {
|
||||
remember(item, imageHeight, imageWidth, density) {
|
||||
if (item != null) {
|
||||
val fillHeight =
|
||||
if (imageHeight != Dp.Unspecified) {
|
||||
|
|
@ -125,14 +128,17 @@ fun SeasonCard(
|
|||
aspectRatio: Float = AspectRatios.TALL,
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||
// Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
|
||||
val spaceBetween = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
|
||||
val spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
|
||||
|
||||
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||
val width = imageHeight * aspectRationToUse
|
||||
val height = imageWidth * (1f / aspectRationToUse)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
|
||||
modifier = modifier.size(width, height),
|
||||
) {
|
||||
Card(
|
||||
|
|
@ -173,8 +179,16 @@ fun SeasonCard(
|
|||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
// Optimization: move animation reads to layout/draw phase
|
||||
.offset {
|
||||
IntOffset(0, (spaceBetween.value - 4.dp).roundToPx())
|
||||
}.layout { measurable, constraints ->
|
||||
val paddingPx = spaceBelow.value.roundToPx()
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height + paddingPx) {
|
||||
placeable.placeRelative(0, 0)
|
||||
}
|
||||
}.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = title ?: "",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
package com.github.damontecres.wholphin.ui.cards
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.components.Genre
|
||||
import com.github.damontecres.wholphin.ui.components.Studio
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun StudioCard(
|
||||
studio: Studio?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = StudioCard(
|
||||
studioId = studio?.id,
|
||||
name = studio?.name,
|
||||
imageUrl = studio?.imageUrl,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun StudioCard(
|
||||
studioId: UUID?,
|
||||
name: String?,
|
||||
imageUrl: String?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val background = rememberIdColor(studioId).copy(alpha = .4f)
|
||||
var error by remember { mutableStateOf(false) }
|
||||
Card(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
colors =
|
||||
CardDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.aspectRatio(AspectRatios.WIDE)
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
) {
|
||||
if (imageUrl != null && !error) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentScale = ContentScale.FillBounds,
|
||||
contentDescription = null,
|
||||
onError = {
|
||||
error = true
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.alpha(.75f)
|
||||
.aspectRatio(AspectRatios.WIDE)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.aspectRatio(AspectRatios.WIDE)
|
||||
.fillMaxSize()
|
||||
.background(background),
|
||||
) {
|
||||
Text(
|
||||
text = name ?: "",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(16.dp)
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun GenreCardPreview() {
|
||||
WholphinTheme {
|
||||
val studio =
|
||||
Studio(
|
||||
UUID.randomUUID(),
|
||||
"Adventure",
|
||||
null,
|
||||
)
|
||||
StudioCard(
|
||||
studio = studio,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
modifier = Modifier.width(180.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -882,15 +883,7 @@ fun CollectionFolderGridContent(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (showTitle) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
GridTitle(title)
|
||||
}
|
||||
val endPadding =
|
||||
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
|
||||
|
|
@ -1146,3 +1139,18 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
|||
listOf()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun GridTitle(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) = Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
|
|
@ -98,3 +99,13 @@ fun ErrorMessage(
|
|||
exception = error.exception,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ErrorMessage(
|
||||
error: DataLoadingState.Error,
|
||||
modifier: Modifier = Modifier,
|
||||
) = ErrorMessage(
|
||||
message = error.message,
|
||||
exception = error.exception,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ 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.StudioFilter
|
||||
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
|
||||
import com.github.damontecres.wholphin.data.filter.YearFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
|
|
@ -206,7 +207,9 @@ fun FilterByButton(
|
|||
val isSelected =
|
||||
remember(currentValue) {
|
||||
when (filterOption) {
|
||||
GenreFilter -> {
|
||||
GenreFilter,
|
||||
StudioFilter,
|
||||
-> {
|
||||
(currentValue as? List<UUID>)
|
||||
.orEmpty()
|
||||
.contains(value.value)
|
||||
|
|
@ -271,7 +274,9 @@ fun FilterByButton(
|
|||
onClick = {
|
||||
val newFilter =
|
||||
when (filterOption) {
|
||||
GenreFilter -> {
|
||||
GenreFilter,
|
||||
StudioFilter,
|
||||
-> {
|
||||
val list = (currentValue as? List<UUID>).orEmpty()
|
||||
val newValue =
|
||||
list
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ class GenreViewModel
|
|||
nameLessThan = letter.toString(),
|
||||
limit = 0,
|
||||
enableTotalRecordCount = true,
|
||||
includeItemTypes = includeItemTypes,
|
||||
)
|
||||
val result by GetGenresRequestHandler.execute(api, request)
|
||||
return@withContext result.totalRecordCount
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.times
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.createStudioDestination
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.StudioCard
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
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.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
||||
import java.util.UUID
|
||||
|
||||
@HiltViewModel(assistedFactory = StudioViewModel.Factory::class)
|
||||
class StudioViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val serverRepository: ServerRepository,
|
||||
val navigationManager: NavigationManager,
|
||||
@Assisted private val itemId: UUID,
|
||||
@Assisted private val includeItemTypes: List<BaseItemKind>?,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
itemId: UUID,
|
||||
includeItemTypes: List<BaseItemKind>?,
|
||||
): StudioViewModel
|
||||
}
|
||||
|
||||
val item = MutableLiveData<BaseItem?>(null)
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val studios = MutableLiveData<List<Studio>>(listOf())
|
||||
|
||||
fun init(cardWidthPx: Int) {
|
||||
loading.value = LoadingState.Loading
|
||||
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
|
||||
val item =
|
||||
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
||||
BaseItem(it, false)
|
||||
}
|
||||
this@StudioViewModel.item.setValueOnMain(item)
|
||||
val request =
|
||||
GetStudiosRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
parentId = itemId,
|
||||
fields = SlimItemFields,
|
||||
includeItemTypes = includeItemTypes,
|
||||
)
|
||||
val studios =
|
||||
GetStudiosRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map {
|
||||
val imageUrl =
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = it.id,
|
||||
imageType = ImageType.THUMB,
|
||||
fillWidth = cardWidthPx,
|
||||
)
|
||||
Studio(it.id, it.name ?: "", imageUrl)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@StudioViewModel.studios.value = studios
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun positionOfLetter(letter: Char): Int =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
GetStudiosRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
parentId = itemId,
|
||||
nameLessThan = letter.toString(),
|
||||
limit = 0,
|
||||
enableTotalRecordCount = true,
|
||||
includeItemTypes = includeItemTypes,
|
||||
)
|
||||
val result by GetStudiosRequestHandler.execute(api, request)
|
||||
return@withContext result.totalRecordCount
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
data class Studio(
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val imageUrl: String?,
|
||||
) : CardGridItem {
|
||||
override val gridId: String get() = id.toString()
|
||||
override val playable: Boolean = false
|
||||
override val sortName: String get() = name
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StudioCardGrid(
|
||||
itemId: UUID,
|
||||
includeItemTypes: List<BaseItemKind>?,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: StudioViewModel =
|
||||
hiltViewModel<StudioViewModel, StudioViewModel.Factory>(
|
||||
creationCallback = { it.create(itemId, includeItemTypes) },
|
||||
),
|
||||
) {
|
||||
val columns = 4
|
||||
val spacing = 16.dp
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val cardWidthPx =
|
||||
remember {
|
||||
with(density) {
|
||||
// Grid has 16dp padding on either side & 16dp spacing between 4 cards
|
||||
// This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster
|
||||
// E.g. on 1080p, this results in 440px versus 395px actual, so only minimal scaling down is required
|
||||
(configuration.screenWidthDp.dp - (2 * 16.dp + 3 * spacing))
|
||||
.div(columns)
|
||||
.roundToPx()
|
||||
}
|
||||
}
|
||||
OneTimeLaunchedEffect {
|
||||
viewModel.init(cardWidthPx)
|
||||
}
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||
val studios by viewModel.studios.observeAsState(listOf())
|
||||
|
||||
val gridFocusRequester = remember { FocusRequester() }
|
||||
when (val st = loading) {
|
||||
LoadingState.Pending,
|
||||
LoadingState.Loading,
|
||||
-> {
|
||||
LoadingPage(modifier.focusable())
|
||||
}
|
||||
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(st, modifier.focusable())
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
Box(modifier = modifier) {
|
||||
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
|
||||
val item by viewModel.item.observeAsState(null)
|
||||
CardGrid(
|
||||
pager = studios,
|
||||
onClickItem = { _, studio ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
createStudioDestination(
|
||||
studioId = studio.id,
|
||||
name = studio.name,
|
||||
parentId = itemId,
|
||||
parentName = item?.title,
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
)
|
||||
},
|
||||
onLongClickItem = { _, _ -> },
|
||||
onClickPlay = { _, _ -> },
|
||||
letterPosition = { viewModel.positionOfLetter(it) },
|
||||
gridFocusRequester = gridFocusRequester,
|
||||
showJumpButtons = false,
|
||||
showLetterButtons = true,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
initialPosition = 0,
|
||||
positionCallback = { columns, position ->
|
||||
},
|
||||
columns = columns,
|
||||
spacing = spacing,
|
||||
cardContent = { item: Studio?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
||||
StudioCard(
|
||||
studio = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,9 +19,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -40,7 +40,6 @@ import androidx.compose.ui.unit.sp
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import timber.log.Timber
|
||||
|
|
@ -60,6 +59,11 @@ fun TabRow(
|
|||
}
|
||||
}
|
||||
var rowHasFocus by remember { mutableStateOf(false) }
|
||||
|
||||
val currentSelectedTabIndex by rememberUpdatedState(selectedTabIndex)
|
||||
val currentFocusRequesters by rememberUpdatedState(focusRequesters)
|
||||
val currentOnClick by rememberUpdatedState(onClick)
|
||||
|
||||
LazyRow(
|
||||
state = state,
|
||||
modifier =
|
||||
|
|
@ -71,14 +75,16 @@ fun TabRow(
|
|||
onEnter = {
|
||||
// If entering from left or right, use last or first tab
|
||||
// Otherwise use the selected tab
|
||||
Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$selectedTabIndex")
|
||||
val index = currentSelectedTabIndex
|
||||
val requesters = currentFocusRequesters
|
||||
Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$index")
|
||||
val focusRequester =
|
||||
if (requestedFocusDirection == FocusDirection.Left) {
|
||||
focusRequesters.lastOrNull()
|
||||
requesters.lastOrNull()
|
||||
} else if (requestedFocusDirection == FocusDirection.Right) {
|
||||
focusRequesters.firstOrNull()
|
||||
requesters.firstOrNull()
|
||||
} else {
|
||||
focusRequesters.getOrNull(selectedTabIndex)
|
||||
requesters.getOrNull(index)
|
||||
}
|
||||
(focusRequester ?: FocusRequester.Default).tryRequestFocus()
|
||||
}
|
||||
|
|
@ -86,15 +92,19 @@ fun TabRow(
|
|||
) {
|
||||
itemsIndexed(tabs) { index, tabTitle ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val onTabClick =
|
||||
remember(index) {
|
||||
{
|
||||
currentOnClick(index)
|
||||
}
|
||||
}
|
||||
Tab(
|
||||
title = tabTitle,
|
||||
selected = index == selectedTabIndex,
|
||||
rowActive = rowHasFocus,
|
||||
interactionSource = interactionSource,
|
||||
onClick = {
|
||||
onClick.invoke(index)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[index]),
|
||||
onClick = onTabClick,
|
||||
modifier = Modifier.focusRequester(focusRequesters.getOrElse(index) { FocusRequester() }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ data class ItemDetailsDialogInfo(
|
|||
val overview: String?,
|
||||
val genres: List<String>,
|
||||
val files: List<MediaSourceInfo>,
|
||||
val studios: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -74,6 +75,12 @@ fun ItemDetailsDialog(
|
|||
text = info.title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
if (info.studios.isNotEmpty()) {
|
||||
Text(
|
||||
text = info.studios.joinToString(", "),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
if (info.genres.isNotEmpty()) {
|
||||
Text(
|
||||
text = info.genres.joinToString(", "),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ 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.data.filter.DefaultTvFilterOptions
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
|
|
@ -29,6 +30,7 @@ import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
|||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
|
||||
import com.github.damontecres.wholphin.ui.components.RecommendedTvShow
|
||||
import com.github.damontecres.wholphin.ui.components.StudioCardGrid
|
||||
import com.github.damontecres.wholphin.ui.components.TabRow
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
|
||||
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
|
||||
|
|
@ -53,6 +55,7 @@ fun CollectionFolderTv(
|
|||
stringResource(R.string.recommended),
|
||||
stringResource(R.string.library),
|
||||
stringResource(R.string.genres),
|
||||
stringResource(R.string.studios),
|
||||
)
|
||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
|
@ -123,6 +126,7 @@ fun CollectionFolderTv(
|
|||
showTitle = false,
|
||||
recursive = true,
|
||||
sortOptions = SeriesSortOptions,
|
||||
filterOptions = DefaultTvFilterOptions,
|
||||
defaultViewOptions = ViewOptionsPoster,
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -151,6 +155,18 @@ fun CollectionFolderTv(
|
|||
)
|
||||
}
|
||||
|
||||
// Studios
|
||||
3 -> {
|
||||
StudioCardGrid(
|
||||
itemId = destination.itemId,
|
||||
includeItemTypes = listOf(BaseItemKind.SERIES),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import org.jellyfin.sdk.model.DeviceInfo
|
|||
import timber.log.Timber
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -75,6 +76,34 @@ class DebugViewModel
|
|||
display.supportedModes.orEmpty()
|
||||
}
|
||||
|
||||
val av1Included by lazy {
|
||||
try {
|
||||
Class.forName("androidx.media3.decoder.av1.Libdav1dVideoRenderer")
|
||||
true
|
||||
} catch (_: ClassNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val ffmpegIncluded by lazy {
|
||||
try {
|
||||
Class.forName("androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer")
|
||||
true
|
||||
} catch (_: ClassNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val libMpvLoaded by lazy {
|
||||
try {
|
||||
System.loadLibrary("player")
|
||||
System.loadLibrary("mpv")
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.rowId?.let {
|
||||
|
|
@ -218,6 +247,7 @@ fun DebugPage(
|
|||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
val buildTime = Date(BuildConfig.BUILD_TIME)
|
||||
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
val installInfo =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
|
|
@ -239,6 +269,11 @@ fun DebugPage(
|
|||
"Version Code: ${pkgInfo.versionCodeLong}",
|
||||
"ClientInfo: ${viewModel.clientInfo}",
|
||||
"Build type: ${BuildConfig.BUILD_TYPE}",
|
||||
"Build flavor: ${BuildConfig.FLAVOR}",
|
||||
"Build time: $buildTime",
|
||||
"FFMPEG included: ${viewModel.ffmpegIncluded}",
|
||||
"AV1 included: ${viewModel.av1Included}",
|
||||
"libmpv loaded: ${viewModel.libMpvLoaded}",
|
||||
"Debug enabled: ${BuildConfig.DEBUG}",
|
||||
"ABIs: ${Build.SUPPORTED_ABIS.toList()}",
|
||||
) + installInfo
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ data class MoreDialogActions(
|
|||
val onSendMediaInfo: (UUID) -> Unit,
|
||||
val onClickDelete: (BaseItem) -> Unit,
|
||||
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
|
||||
val onClickRemoveFromNextUp: (BaseItem) -> Unit = {},
|
||||
)
|
||||
|
||||
enum class ClearChosenStreams {
|
||||
|
|
@ -270,6 +271,8 @@ fun buildMoreDialogItemsForHome(
|
|||
favorite: Boolean,
|
||||
canDelete: Boolean,
|
||||
actions: MoreDialogActions,
|
||||
canRemoveContinueWatching: Boolean = false,
|
||||
canRemoveNextUp: Boolean = false,
|
||||
): List<DialogItem> =
|
||||
buildList {
|
||||
val itemId = item.id
|
||||
|
|
@ -347,6 +350,26 @@ fun buildMoreDialogItemsForHome(
|
|||
},
|
||||
)
|
||||
}
|
||||
if (canRemoveContinueWatching && !watched && playbackPosition > Duration.ZERO) {
|
||||
add(
|
||||
DialogItem(
|
||||
text = R.string.remove_continue_watching,
|
||||
iconStringRes = R.string.fa_eye,
|
||||
) {
|
||||
actions.onClickWatch.invoke(itemId, false)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) {
|
||||
add(
|
||||
DialogItem(
|
||||
text = R.string.remove_next_up,
|
||||
iconStringRes = R.string.fa_tag,
|
||||
) {
|
||||
actions.onClickRemoveFromNextUp.invoke(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
|||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
|
|
@ -386,6 +387,7 @@ fun PersonPageContent(
|
|||
DiscoverRowData(
|
||||
stringResource(R.string.discover),
|
||||
DataLoadingState.Success(discovered),
|
||||
DiscoverRequestType.UNKNOWN,
|
||||
),
|
||||
onClickItem = { index: Int, item: DiscoverItem ->
|
||||
position = RowColumn(DISCOVER_ROW, index)
|
||||
|
|
|
|||
|
|
@ -341,7 +341,7 @@ fun DiscoverMovieDetailsContent(
|
|||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
showOverlay = false,
|
||||
showOverlay = true,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ fun DiscoverSeriesDetailsContent(
|
|||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
showOverlay = false,
|
||||
showOverlay = true,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import androidx.compose.runtime.livedata.observeAsState
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
|
@ -28,14 +29,13 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.Chapter
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
||||
import com.github.damontecres.wholphin.data.model.studioNames
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
|
|
@ -67,11 +67,12 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
|||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
|
|
@ -98,16 +99,7 @@ fun MovieDetails(
|
|||
viewModel.init()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
val item by viewModel.item.observeAsState()
|
||||
val people by viewModel.people.observeAsState(listOf())
|
||||
val chapters by viewModel.chapters.observeAsState(listOf())
|
||||
val trailers by viewModel.trailers.observeAsState(listOf())
|
||||
val extras by viewModel.extras.observeAsState(listOf())
|
||||
val similar by viewModel.similar.observeAsState(listOf())
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
||||
val discovered by viewModel.discovered.collectAsState()
|
||||
val canDelete by viewModel.canDelete.collectAsState()
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
|
|
@ -140,19 +132,21 @@ fun MovieDetails(
|
|||
onClickDelete = { showDeleteDialog = it },
|
||||
)
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
when (val s = state.loading) {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(s, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
item?.let { movie ->
|
||||
is DataLoadingState.Success -> {
|
||||
val unknownStr = stringResource(R.string.unknown)
|
||||
val movie by rememberUpdatedState(s.data)
|
||||
val chosenStreams by rememberUpdatedState(state.chosenStreams)
|
||||
LifecycleResumeEffect(destination.itemId) {
|
||||
viewModel.maybePlayThemeSong(
|
||||
destination.itemId,
|
||||
|
|
@ -165,12 +159,7 @@ fun MovieDetails(
|
|||
MovieDetailsContent(
|
||||
preferences = preferences,
|
||||
movie = movie,
|
||||
chosenStreams = chosenStreams,
|
||||
people = people,
|
||||
chapters = chapters,
|
||||
extras = extras,
|
||||
trailers = trailers,
|
||||
similar = similar,
|
||||
state = state,
|
||||
onClickItem = { index, item ->
|
||||
viewModel.navigateTo(item.destination())
|
||||
},
|
||||
|
|
@ -193,10 +182,11 @@ fun MovieDetails(
|
|||
overviewOnClick = {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(
|
||||
title = movie.name ?: context.getString(R.string.unknown),
|
||||
title = movie.name ?: unknownStr,
|
||||
overview = movie.data.overview,
|
||||
genres = movie.data.genres.orEmpty(),
|
||||
files = movie.data.mediaSources.orEmpty(),
|
||||
studios = movie.studioNames,
|
||||
)
|
||||
},
|
||||
moreOnClick = {
|
||||
|
|
@ -212,8 +202,8 @@ fun MovieDetails(
|
|||
favorite = movie.data.userData?.isFavorite ?: false,
|
||||
seriesId = null,
|
||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
||||
canDelete = canDelete,
|
||||
canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
|
||||
canDelete = state.canDelete,
|
||||
actions = moreActions,
|
||||
onChooseVersion = {
|
||||
chooseVersion =
|
||||
|
|
@ -230,7 +220,6 @@ fun MovieDetails(
|
|||
moreDialog = null
|
||||
},
|
||||
onChooseTracks = { type ->
|
||||
|
||||
viewModel.streamChoiceService
|
||||
.chooseSource(
|
||||
movie.data,
|
||||
|
|
@ -262,9 +251,7 @@ fun MovieDetails(
|
|||
onShowOverview = {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(
|
||||
title =
|
||||
movie.name
|
||||
?: context.getString(R.string.unknown),
|
||||
title = movie.name ?: unknownStr,
|
||||
overview = movie.data.overview,
|
||||
genres = movie.data.genres.orEmpty(),
|
||||
files = movie.data.mediaSources.orEmpty(),
|
||||
|
|
@ -321,17 +308,15 @@ fun MovieDetails(
|
|||
onClickExtra = { index, extra ->
|
||||
viewModel.navigateTo(extra.destination)
|
||||
},
|
||||
discovered = discovered,
|
||||
onClickDiscover = { index, item ->
|
||||
viewModel.navigateTo(item.destination)
|
||||
},
|
||||
canDelete = canDelete,
|
||||
deleteOnClick = { showDeleteDialog = movie },
|
||||
canDelete = state.canDelete,
|
||||
deleteOnClick = { showDeleteDialog = state.movie },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
overviewDialog?.let { info ->
|
||||
ItemDetailsDialog(
|
||||
info = info,
|
||||
|
|
@ -403,13 +388,7 @@ private const val DISCOVER_ROW = SIMILAR_ROW + 1
|
|||
fun MovieDetailsContent(
|
||||
preferences: UserPreferences,
|
||||
movie: BaseItem,
|
||||
chosenStreams: ChosenStreams?,
|
||||
people: List<Person>,
|
||||
chapters: List<Chapter>,
|
||||
trailers: List<Trailer>,
|
||||
extras: List<ExtrasItem>,
|
||||
similar: List<BaseItem>,
|
||||
discovered: List<DiscoverItem>,
|
||||
state: MovieState,
|
||||
playOnClick: (Duration) -> Unit,
|
||||
trailerOnClick: (Trailer) -> Unit,
|
||||
overviewOnClick: () -> Unit,
|
||||
|
|
@ -454,7 +433,7 @@ fun MovieDetailsContent(
|
|||
MovieDetailsHeader(
|
||||
preferences = preferences,
|
||||
movie = movie,
|
||||
chosenStreams = chosenStreams,
|
||||
chosenStreams = state.chosenStreams,
|
||||
bringIntoViewRequester = bringIntoViewRequester,
|
||||
overviewOnClick = overviewOnClick,
|
||||
modifier =
|
||||
|
|
@ -481,7 +460,7 @@ fun MovieDetailsContent(
|
|||
}
|
||||
}
|
||||
},
|
||||
trailers = trailers,
|
||||
trailers = state.trailers,
|
||||
trailerOnClick = {
|
||||
position = TRAILER_ROW
|
||||
trailerOnClick.invoke(it)
|
||||
|
|
@ -496,7 +475,7 @@ fun MovieDetailsContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (people.isNotEmpty()) {
|
||||
state.people.letNotEmpty { people ->
|
||||
item {
|
||||
PersonRow(
|
||||
people = people,
|
||||
|
|
@ -515,7 +494,7 @@ fun MovieDetailsContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (chapters.isNotEmpty()) {
|
||||
state.chapters.letNotEmpty { chapters ->
|
||||
item {
|
||||
ChapterRow(
|
||||
chapters = chapters,
|
||||
|
|
@ -532,7 +511,7 @@ fun MovieDetailsContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (extras.isNotEmpty()) {
|
||||
state.extras.letNotEmpty { extras ->
|
||||
item {
|
||||
ExtrasRow(
|
||||
extras = extras,
|
||||
|
|
@ -548,7 +527,7 @@ fun MovieDetailsContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (similar.isNotEmpty()) {
|
||||
state.similar.letNotEmpty { similar ->
|
||||
item {
|
||||
val imageHeight =
|
||||
remember(movie.type) {
|
||||
|
|
@ -587,13 +566,14 @@ fun MovieDetailsContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (discovered.isNotEmpty()) {
|
||||
state.discovered.letNotEmpty { discovered ->
|
||||
item {
|
||||
DiscoverRow(
|
||||
row =
|
||||
DiscoverRowData(
|
||||
stringResource(R.string.discover),
|
||||
DataLoadingState.Success(discovered),
|
||||
type = DiscoverRequestType.UNKNOWN,
|
||||
),
|
||||
onClickItem = { index: Int, item: DiscoverItem ->
|
||||
position = DISCOVER_ROW
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.movie
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||
|
|
@ -30,36 +28,28 @@ import com.github.damontecres.wholphin.services.TrailerService
|
|||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.combinePair
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
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.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
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 kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
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.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
@HiltViewModel(assistedFactory = MovieViewModel.Factory::class)
|
||||
|
|
@ -89,94 +79,99 @@ class MovieViewModel
|
|||
fun create(itemId: UUID): MovieViewModel
|
||||
}
|
||||
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val item = MutableLiveData<BaseItem?>(null)
|
||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||
val people = MutableLiveData<List<Person>>(listOf())
|
||||
val chapters = MutableLiveData<List<Chapter>>(listOf())
|
||||
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
|
||||
val similar = MutableLiveData<List<BaseItem>>()
|
||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||
|
||||
val canDelete = MutableStateFlow(false)
|
||||
private val _state = MutableStateFlow(MovieState())
|
||||
val state: StateFlow<MovieState> = _state
|
||||
|
||||
init {
|
||||
init()
|
||||
viewModelScope.launchDefault {
|
||||
item
|
||||
.asFlow()
|
||||
.filterNotNull()
|
||||
.combinePair(userPreferencesService.flow.map { it.appPreferences })
|
||||
.collectLatest { (item, preferences) ->
|
||||
canDelete.update { mediaManagementService.canDelete(item, preferences) }
|
||||
userPreferencesService.flow.collectLatest { preferences ->
|
||||
_state.update {
|
||||
val canDelete =
|
||||
it.movie?.let {
|
||||
mediaManagementService.canDelete(
|
||||
it,
|
||||
preferences.appPreferences,
|
||||
)
|
||||
}
|
||||
it.copy(
|
||||
canDelete = canDelete ?: false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchAndSetItem(): Deferred<BaseItem> =
|
||||
viewModelScope.async(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
private suspend fun getMovie(): BaseItem {
|
||||
val item =
|
||||
api.userLibraryApi.getItem(itemId).content.let {
|
||||
BaseItem.from(it, api)
|
||||
BaseItem(it)
|
||||
}
|
||||
this@MovieViewModel.item.setValueOnMain(item)
|
||||
item
|
||||
return item
|
||||
}
|
||||
|
||||
fun init(): Job =
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
val item = fetchAndSetItem().await()
|
||||
val result =
|
||||
viewModelScope.launchDefault {
|
||||
val movie =
|
||||
try {
|
||||
getMovie()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Failed to fetch movie %s", itemId)
|
||||
_state.update { it.copy(loading = DataLoadingState.Error(ex)) }
|
||||
return@launchDefault
|
||||
}
|
||||
val chosenStreams =
|
||||
itemPlaybackRepository.getSelectedTracks(
|
||||
item.id,
|
||||
item,
|
||||
itemId,
|
||||
movie,
|
||||
userPreferencesService.getCurrent(),
|
||||
)
|
||||
val remoteTrailers = trailerService.getRemoteTrailers(item)
|
||||
withContext(Dispatchers.Main) {
|
||||
this@MovieViewModel.item.value = item
|
||||
chosenStreams.value = result
|
||||
this@MovieViewModel.trailers.value = remoteTrailers
|
||||
loading.value = LoadingState.Success
|
||||
backdropService.submit(item)
|
||||
val remoteTrailers = trailerService.getRemoteTrailers(movie)
|
||||
val chapters = Chapter.fromDto(movie.data, api)
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = DataLoadingState.Success(movie),
|
||||
chosenStreams = chosenStreams,
|
||||
trailers = remoteTrailers,
|
||||
chapters = chapters,
|
||||
)
|
||||
}
|
||||
backdropService.submit(movie)
|
||||
viewModelScope.launchIO {
|
||||
trailerService.getLocalTrailers(item).letNotEmpty { localTrailers ->
|
||||
withContext(Dispatchers.Main) {
|
||||
this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
|
||||
trailerService.getLocalTrailers(movie).letNotEmpty { localTrailers ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
trailers = localTrailers + remoteTrailers,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val people = peopleFavorites.getPeopleFor(item)
|
||||
this@MovieViewModel.people.setValueOnMain(people)
|
||||
val people = peopleFavorites.getPeopleFor(movie)
|
||||
_state.update {
|
||||
it.copy(
|
||||
people = people,
|
||||
)
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val extras = extrasService.getExtras(item.id)
|
||||
this@MovieViewModel.extras.setValueOnMain(extras)
|
||||
val extras = extrasService.getExtras(itemId)
|
||||
_state.update {
|
||||
it.copy(
|
||||
extras = extras,
|
||||
)
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val results = seerrService.similar(item).orEmpty()
|
||||
discovered.update { results }
|
||||
val results = seerrService.similar(movie).orEmpty()
|
||||
_state.update {
|
||||
it.copy(
|
||||
discovered = results,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
chapters.value = Chapter.fromDto(item.data, api)
|
||||
}
|
||||
if (!similar.isInitialized) {
|
||||
if (state.value.similar.isEmpty()) {
|
||||
val similar =
|
||||
api.libraryApi
|
||||
.getSimilarItems(
|
||||
|
|
@ -187,31 +182,48 @@ class MovieViewModel
|
|||
limit = 25,
|
||||
),
|
||||
).content.items
|
||||
.map { BaseItem.Companion.from(it, api) }
|
||||
this@MovieViewModel.similar.setValueOnMain(similar)
|
||||
.map { BaseItem(it) }
|
||||
|
||||
_state.update { it.copy(similar = similar) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setWatched(
|
||||
itemId: UUID,
|
||||
played: Boolean,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
) = viewModelScope.launchDefault {
|
||||
try {
|
||||
favoriteWatchManager.setWatched(itemId, played)
|
||||
fetchAndSetItem()
|
||||
getMovie().let { movie ->
|
||||
_state.update {
|
||||
it.copy(loading = DataLoadingState.Success(movie))
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error updating watch status for movie %s", itemId)
|
||||
showToast(context, "Something went wrong...")
|
||||
}
|
||||
}
|
||||
|
||||
fun setFavorite(
|
||||
itemId: UUID,
|
||||
favorite: Boolean,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
) = viewModelScope.launchDefault {
|
||||
try {
|
||||
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||
val item = item.value
|
||||
fetchAndSetItem()
|
||||
if (item != null && itemId != item.id) {
|
||||
viewModelScope.launchIO {
|
||||
val people = peopleFavorites.getPeopleFor(item)
|
||||
this@MovieViewModel.people.setValueOnMain(people)
|
||||
val movie = getMovie()
|
||||
_state.update {
|
||||
it.copy(loading = DataLoadingState.Success(movie))
|
||||
}
|
||||
if (itemId != movie.id) {
|
||||
viewModelScope.launchIO {
|
||||
val people = peopleFavorites.getPeopleFor(movie)
|
||||
_state.update { it.copy(people = people) }
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error updating favorite %s", itemId)
|
||||
showToast(context, "Something went wrong...")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -227,9 +239,7 @@ class MovieViewModel
|
|||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = chosen
|
||||
}
|
||||
_state.update { it.copy(chosenStreams = chosen) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -253,9 +263,7 @@ class MovieViewModel
|
|||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = chosen
|
||||
}
|
||||
_state.update { it.copy(chosenStreams = chosen) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -283,14 +291,14 @@ class MovieViewModel
|
|||
fun clearChosenStreams(chosenStreams: ChosenStreams?) {
|
||||
viewModelScope.launchIO {
|
||||
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
|
||||
item.value?.let { item ->
|
||||
state.value.movie?.let { item ->
|
||||
val result =
|
||||
itemPlaybackRepository.getSelectedTracks(
|
||||
itemId,
|
||||
item,
|
||||
userPreferencesService.getCurrent(),
|
||||
)
|
||||
this@MovieViewModel.chosenStreams.setValueOnMain(result)
|
||||
_state.update { it.copy(chosenStreams = result) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -301,3 +309,17 @@ class MovieViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MovieState(
|
||||
val loading: DataLoadingState<BaseItem> = DataLoadingState.Pending,
|
||||
val trailers: List<Trailer> = emptyList(),
|
||||
val people: List<Person> = emptyList(),
|
||||
val chapters: List<Chapter> = emptyList(),
|
||||
val extras: List<ExtrasItem> = emptyList(),
|
||||
val similar: List<BaseItem> = emptyList(),
|
||||
val discovered: List<DiscoverItem> = emptyList(),
|
||||
val chosenStreams: ChosenStreams? = null,
|
||||
val canDelete: Boolean = false,
|
||||
) {
|
||||
val movie: BaseItem? = (loading as? DataLoadingState.Success<BaseItem>)?.data
|
||||
}
|
||||
|
|
|
|||
|
|
@ -539,6 +539,9 @@ fun AlbumDetailsPage(
|
|||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
aspectRatio = AspectRatios.WIDE,
|
||||
played = item?.played ?: false,
|
||||
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
|
||||
favorite = item?.favorite ?: false,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -568,6 +568,9 @@ fun ArtistDetailsPage(
|
|||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
aspectRatio = AspectRatios.WIDE,
|
||||
played = item?.played ?: false,
|
||||
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
|
||||
favorite = item?.favorite ?: false,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -41,12 +41,14 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.data.model.studioNames
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
|
|
@ -87,6 +89,7 @@ import com.github.damontecres.wholphin.ui.letNotEmpty
|
|||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -212,6 +215,7 @@ fun SeriesDetails(
|
|||
title = item.name ?: context.getString(R.string.unknown),
|
||||
overview = item.data.overview,
|
||||
genres = item.data.genres.orEmpty(),
|
||||
studios = item.studioNames,
|
||||
files = listOf(),
|
||||
)
|
||||
},
|
||||
|
|
@ -396,6 +400,7 @@ fun SeriesDetailsContent(
|
|||
series = series,
|
||||
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||
overviewOnClick = overviewOnClick,
|
||||
bringIntoViewRequester = bringIntoViewRequester,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -653,6 +658,7 @@ fun SeriesDetailsContent(
|
|||
DiscoverRowData(
|
||||
stringResource(R.string.discover),
|
||||
DataLoadingState.Success(discovered),
|
||||
type = DiscoverRequestType.UNKNOWN,
|
||||
),
|
||||
onClickItem = { index: Int, item: DiscoverItem ->
|
||||
position = DISCOVER_ROW
|
||||
|
|
@ -684,6 +690,7 @@ fun SeriesDetailsHeader(
|
|||
series: BaseItem,
|
||||
showLogo: Boolean,
|
||||
overviewOnClick: () -> Unit,
|
||||
bringIntoViewRequester: BringIntoViewRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
|
@ -709,8 +716,16 @@ fun SeriesDetailsHeader(
|
|||
null,
|
||||
Modifier.padding(start = HeaderUtils.startPadding),
|
||||
)
|
||||
dto.studios?.let {
|
||||
val studios = remember { series.studioNames }
|
||||
GenreText(
|
||||
studios,
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = HeaderUtils.startPadding),
|
||||
)
|
||||
}
|
||||
dto.genres?.letNotEmpty {
|
||||
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 8.dp))
|
||||
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp))
|
||||
}
|
||||
dto.overview?.let { overview ->
|
||||
OverviewText(
|
||||
|
|
@ -718,6 +733,14 @@ fun SeriesDetailsHeader(
|
|||
maxLines = 3,
|
||||
onClick = overviewOnClick,
|
||||
textBoxHeight = Dp.Unspecified,
|
||||
modifier =
|
||||
Modifier.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -39,6 +40,7 @@ import androidx.compose.ui.focus.focusRequester
|
|||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -95,7 +97,7 @@ fun SeriesOverviewContent(
|
|||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) }
|
||||
var selectedTabIndex by rememberSaveable(position.seasonTabIndex) { mutableIntStateOf(position.seasonTabIndex) }
|
||||
LaunchedEffect(selectedTabIndex) {
|
||||
logTab("series_overview", selectedTabIndex)
|
||||
}
|
||||
|
|
@ -120,6 +122,8 @@ fun SeriesOverviewContent(
|
|||
}
|
||||
val focusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } }
|
||||
|
||||
val currentOnChangeSeason by rememberUpdatedState(onChangeSeason)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
|
|
@ -154,10 +158,13 @@ fun SeriesOverviewContent(
|
|||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
tabs = tabs,
|
||||
onClick = {
|
||||
onClick =
|
||||
remember {
|
||||
{
|
||||
selectedTabIndex = it
|
||||
onChangeSeason.invoke(it)
|
||||
currentOnChangeSeason(it)
|
||||
requestFocusAfterSeason = true
|
||||
}
|
||||
},
|
||||
focusRequesters = focusRequesters,
|
||||
modifier =
|
||||
|
|
@ -254,14 +261,21 @@ fun SeriesOverviewContent(
|
|||
).ifElse(
|
||||
episodeIndex == epPosition,
|
||||
Modifier.focusRequester(episodeRowFocusRequester),
|
||||
).ifElse(
|
||||
episodeIndex != position.episodeRowIndex,
|
||||
Modifier
|
||||
.background(
|
||||
Color.Black,
|
||||
).background(
|
||||
if (episodeIndex != position.episodeRowIndex) {
|
||||
Color.Black
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
).alpha(dimming),
|
||||
).onFocusChanged {
|
||||
).graphicsLayer {
|
||||
alpha =
|
||||
if (episodeIndex != position.episodeRowIndex) {
|
||||
dimming
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
}.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
scope.launch {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
|
|
@ -313,17 +327,9 @@ fun SeriesOverviewContent(
|
|||
}
|
||||
}
|
||||
|
||||
val castAndCrew =
|
||||
val (guestStars, castAndCrew) =
|
||||
remember(peopleInEpisode) {
|
||||
peopleInEpisode.filterNot {
|
||||
it.type == PersonKind.GUEST_STAR
|
||||
}
|
||||
}
|
||||
val guestStars =
|
||||
remember(peopleInEpisode) {
|
||||
peopleInEpisode.filter {
|
||||
it.type == PersonKind.GUEST_STAR
|
||||
}
|
||||
peopleInEpisode.partition { it.type == PersonKind.GUEST_STAR }
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
package com.github.damontecres.wholphin.ui.discover
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
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 androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrApi
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GridTitle
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.DiscoverMovieRequestHandler
|
||||
import com.github.damontecres.wholphin.util.DiscoverRequestPager
|
||||
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
||||
import com.github.damontecres.wholphin.util.DiscoverTvRequestHandler
|
||||
import com.github.damontecres.wholphin.util.TrendingRequestHandler
|
||||
import com.github.damontecres.wholphin.util.UpcomingMovieRequestHandler
|
||||
import com.github.damontecres.wholphin.util.UpcomingTvRequestHandler
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltViewModel(assistedFactory = DiscoverRequestViewModel.Factory::class)
|
||||
class DiscoverRequestViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val seerrService: SeerrService,
|
||||
val navigationManager: NavigationManager,
|
||||
private val api: SeerrApi,
|
||||
@Assisted val type: DiscoverRequestType,
|
||||
@Assisted startIndex: Int,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
type: DiscoverRequestType,
|
||||
startIndex: Int,
|
||||
): DiscoverRequestViewModel
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(DiscoverRequestState())
|
||||
val state: StateFlow<DiscoverRequestState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launchDefault {
|
||||
try {
|
||||
val pager =
|
||||
when (type) {
|
||||
DiscoverRequestType.DISCOVER_TV -> {
|
||||
DiscoverRequestPager(
|
||||
api,
|
||||
DiscoverTvRequestHandler,
|
||||
seerrService::createDiscoverItem,
|
||||
viewModelScope,
|
||||
)
|
||||
}
|
||||
|
||||
DiscoverRequestType.DISCOVER_MOVIES -> {
|
||||
DiscoverRequestPager(
|
||||
api,
|
||||
DiscoverMovieRequestHandler,
|
||||
seerrService::createDiscoverItem,
|
||||
viewModelScope,
|
||||
)
|
||||
}
|
||||
|
||||
DiscoverRequestType.TRENDING -> {
|
||||
DiscoverRequestPager(
|
||||
api,
|
||||
TrendingRequestHandler,
|
||||
seerrService::createDiscoverItem,
|
||||
viewModelScope,
|
||||
)
|
||||
}
|
||||
|
||||
DiscoverRequestType.UPCOMING_TV -> {
|
||||
DiscoverRequestPager(
|
||||
api,
|
||||
UpcomingTvRequestHandler,
|
||||
seerrService::createDiscoverItem,
|
||||
viewModelScope,
|
||||
)
|
||||
}
|
||||
|
||||
DiscoverRequestType.UPCOMING_MOVIES -> {
|
||||
DiscoverRequestPager(
|
||||
api,
|
||||
UpcomingMovieRequestHandler,
|
||||
seerrService::createDiscoverItem,
|
||||
viewModelScope,
|
||||
)
|
||||
}
|
||||
|
||||
DiscoverRequestType.UNKNOWN -> {
|
||||
throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN")
|
||||
}
|
||||
}.init(startIndex)
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = DataLoadingState.Success(pager),
|
||||
)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error initializing %s", type)
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = DataLoadingState.Error(ex),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DiscoverRequestState(
|
||||
val loading: DataLoadingState<List<DiscoverItem?>> = DataLoadingState.Pending,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DiscoverRequestGrid(
|
||||
destination: Destination.DiscoverMoreResult,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: DiscoverRequestViewModel =
|
||||
hiltViewModel<DiscoverRequestViewModel, DiscoverRequestViewModel.Factory>(
|
||||
creationCallback = { it.create(destination.type, destination.startIndex) },
|
||||
),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
when (val s = state.loading) {
|
||||
DataLoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(s, modifier)
|
||||
}
|
||||
|
||||
is DataLoadingState.Success<List<DiscoverItem?>> -> {
|
||||
val gridFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
GridTitle(stringResource(destination.type.stringRes))
|
||||
|
||||
CardGrid(
|
||||
initialPosition = destination.startIndex,
|
||||
pager = s.data,
|
||||
onClickItem = { index, item ->
|
||||
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
|
||||
},
|
||||
onLongClickItem = { index, item -> },
|
||||
onClickPlay = { _, _ -> },
|
||||
letterPosition = { 0 },
|
||||
gridFocusRequester = gridFocusRequester,
|
||||
showJumpButtons = false,
|
||||
showLetterButtons = false,
|
||||
cardContent = { item, onClick, onLongClick, mod ->
|
||||
DiscoverItemCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = mod,
|
||||
width = Dp.Unspecified,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
package com.github.damontecres.wholphin.ui.discover
|
||||
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverViewMoreCard
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRowTitle
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
|
||||
@Composable
|
||||
fun DiscoverRow(
|
||||
row: DiscoverRowData,
|
||||
onClickItem: (Int, DiscoverItem) -> Unit,
|
||||
onLongClickItem: (Int, DiscoverItem) -> Unit,
|
||||
onCardFocus: (Int) -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
enableViewMore: Boolean = false,
|
||||
onClickViewMore: () -> Unit = {},
|
||||
) {
|
||||
when (val state = row.items) {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(state.message, state.exception, modifier)
|
||||
}
|
||||
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = row.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.loading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataLoadingState.Success<List<DiscoverItem>> -> {
|
||||
DiscoverItemRow(
|
||||
title = row.title,
|
||||
items = state.data,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = onLongClickItem,
|
||||
onCardFocus = onCardFocus,
|
||||
enableViewMore = enableViewMore,
|
||||
onClickViewMore = onClickViewMore,
|
||||
modifier = modifier.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DiscoverItemRow(
|
||||
title: String,
|
||||
items: List<DiscoverItem?>,
|
||||
onClickItem: (Int, DiscoverItem) -> Unit,
|
||||
onLongClickItem: (Int, DiscoverItem) -> Unit,
|
||||
onCardFocus: (Int) -> Unit,
|
||||
enableViewMore: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalPadding: Dp = 16.dp,
|
||||
onClickViewMore: () -> Unit = {},
|
||||
) {
|
||||
val state = rememberLazyListState()
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var position by rememberInt()
|
||||
|
||||
val currentOnClickItem by rememberUpdatedState(onClickItem)
|
||||
val currentOnLongClickItem by rememberUpdatedState(onLongClickItem)
|
||||
val currentOnCardFocus by rememberUpdatedState(onCardFocus)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier.focusProperties {
|
||||
onEnter = {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
},
|
||||
) {
|
||||
ItemRowTitle(title)
|
||||
|
||||
LazyRow(
|
||||
state = state,
|
||||
horizontalArrangement = Arrangement.spacedBy(horizontalPadding),
|
||||
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusGroup()
|
||||
.focusRestorer(firstFocus)
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
itemsIndexed(items) { index, item ->
|
||||
val cardModifier =
|
||||
remember(index, position) {
|
||||
if (index == position) {
|
||||
Modifier.focusRequester(firstFocus)
|
||||
} else {
|
||||
Modifier
|
||||
}.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
currentOnCardFocus.invoke(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onClick =
|
||||
remember(index, item) {
|
||||
{
|
||||
position = index
|
||||
if (item != null) currentOnClickItem(index, item)
|
||||
}
|
||||
}
|
||||
|
||||
val onLongClick =
|
||||
remember(index, item) {
|
||||
{
|
||||
position = index
|
||||
if (item != null) currentOnLongClickItem(index, item)
|
||||
}
|
||||
}
|
||||
|
||||
DiscoverItemCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
showOverlay = true,
|
||||
modifier = cardModifier,
|
||||
)
|
||||
}
|
||||
if (enableViewMore) {
|
||||
item {
|
||||
DiscoverViewMoreCard(
|
||||
onClick =
|
||||
remember {
|
||||
{
|
||||
position = items.size
|
||||
onClickViewMore.invoke()
|
||||
}
|
||||
},
|
||||
onLongClick = {},
|
||||
modifier =
|
||||
Modifier
|
||||
.ifElse(items.size == position, Modifier.focusRequester(firstFocus))
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
currentOnCardFocus.invoke(items.size)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,18 +23,14 @@ 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.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||
|
|
@ -42,23 +38,22 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
|||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.listToDotString
|
||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -68,7 +63,6 @@ class SeerrDiscoverViewModel
|
|||
@param:ApplicationContext private val context: Context,
|
||||
private val seerrService: SeerrService,
|
||||
val navigationManager: NavigationManager,
|
||||
private val api: ApiClient,
|
||||
private val backdropService: BackdropService,
|
||||
) : ViewModel() {
|
||||
val state = MutableStateFlow<DiscoverState>(DiscoverState())
|
||||
|
|
@ -79,24 +73,53 @@ class SeerrDiscoverViewModel
|
|||
backdropService.clearBackdrop()
|
||||
}
|
||||
fetchAndUpdateState(seerrService::discoverMovies) {
|
||||
this.copy(movies = DiscoverRowData(context.getString(R.string.movies), it))
|
||||
this.copy(
|
||||
movies =
|
||||
DiscoverRowData(
|
||||
context.getString(R.string.movies),
|
||||
it,
|
||||
DiscoverRequestType.DISCOVER_MOVIES,
|
||||
),
|
||||
)
|
||||
}
|
||||
fetchAndUpdateState(seerrService::discoverTv) {
|
||||
this.copy(tv = DiscoverRowData(context.getString(R.string.tv_shows), it))
|
||||
this.copy(
|
||||
tv =
|
||||
DiscoverRowData(
|
||||
context.getString(R.string.tv_shows),
|
||||
it,
|
||||
DiscoverRequestType.DISCOVER_TV,
|
||||
),
|
||||
)
|
||||
}
|
||||
fetchAndUpdateState(seerrService::trending) {
|
||||
this.copy(trending = DiscoverRowData(context.getString(R.string.trending), it))
|
||||
this.copy(
|
||||
trending =
|
||||
DiscoverRowData(
|
||||
context.getString(R.string.trending),
|
||||
it,
|
||||
DiscoverRequestType.TRENDING,
|
||||
),
|
||||
)
|
||||
}
|
||||
fetchAndUpdateState(seerrService::upcomingMovies) {
|
||||
this.copy(
|
||||
upcomingMovies =
|
||||
DiscoverRowData(context.getString(R.string.upcoming_movies), it),
|
||||
DiscoverRowData(
|
||||
context.getString(R.string.upcoming_movies),
|
||||
it,
|
||||
DiscoverRequestType.UPCOMING_MOVIES,
|
||||
),
|
||||
)
|
||||
}
|
||||
fetchAndUpdateState(seerrService::upcomingTv) {
|
||||
this.copy(
|
||||
upcomingTv =
|
||||
DiscoverRowData(context.getString(R.string.upcoming_tv), it),
|
||||
DiscoverRowData(
|
||||
context.getString(R.string.upcoming_tv),
|
||||
it,
|
||||
DiscoverRequestType.UPCOMING_TV,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +150,8 @@ class SeerrDiscoverViewModel
|
|||
if (item != null) {
|
||||
backdropService.submit("discover_${item.id}", item.backDropUrl)
|
||||
fetchRating(item)
|
||||
} else {
|
||||
backdropService.clearBackdrop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -145,6 +170,7 @@ class SeerrDiscoverViewModel
|
|||
return@launchIO
|
||||
}
|
||||
val result =
|
||||
try {
|
||||
when (item.type) {
|
||||
SeerrItemType.MOVIE -> {
|
||||
DiscoverRating(
|
||||
|
|
@ -166,6 +192,18 @@ class SeerrDiscoverViewModel
|
|||
DiscoverRating(null, null)
|
||||
}
|
||||
}
|
||||
} catch (ex: ClientException) {
|
||||
if (ex.statusCode == 404) {
|
||||
Timber.w("No rating found for %s", item.id)
|
||||
DiscoverRating(null, null)
|
||||
} else {
|
||||
Timber.e(ex, "Error getting rating for %s", item.id)
|
||||
return@launchIO
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error getting rating for %s", item.id)
|
||||
return@launchIO
|
||||
}
|
||||
ratingCache.put(item.id, result)
|
||||
rating.update {
|
||||
ratingCache.asMap().toMap()
|
||||
|
|
@ -177,9 +215,10 @@ class SeerrDiscoverViewModel
|
|||
data class DiscoverRowData(
|
||||
val title: String,
|
||||
val items: DataLoadingState<List<DiscoverItem>>,
|
||||
val type: DiscoverRequestType,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = DiscoverRowData("", DataLoadingState.Pending)
|
||||
val EMPTY = DiscoverRowData("", DataLoadingState.Pending, DiscoverRequestType.UNKNOWN)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,6 +325,15 @@ fun SeerrDiscoverPage(
|
|||
onLongClickItem = { index, item -> },
|
||||
onCardFocus = { index -> position = RowColumn(rowIndex, index) },
|
||||
focusRequester = focusRequesters[rowIndex],
|
||||
enableViewMore = row.type != DiscoverRequestType.UNKNOWN,
|
||||
onClickViewMore = {
|
||||
(row.items as? DataLoadingState.Success<List<DiscoverItem>>)?.data?.size?.let {
|
||||
position = RowColumn(rowIndex, it)
|
||||
}
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.DiscoverMoreResult(row.type),
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
|
|
@ -296,63 +344,3 @@ fun SeerrDiscoverPage(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DiscoverRow(
|
||||
row: DiscoverRowData,
|
||||
onClickItem: (Int, DiscoverItem) -> Unit,
|
||||
onLongClickItem: (Int, DiscoverItem) -> Unit,
|
||||
onCardFocus: (Int) -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (val state = row.items) {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(state.message, state.exception, modifier)
|
||||
}
|
||||
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = row.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.loading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataLoadingState.Success<List<DiscoverItem>> -> {
|
||||
ItemRow(
|
||||
title = row.title,
|
||||
items = state.data,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = onLongClickItem,
|
||||
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||
DiscoverItemCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
showOverlay = false,
|
||||
modifier =
|
||||
mod.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
onCardFocus.invoke(index)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = modifier.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ 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.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -45,6 +46,7 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
|
|
@ -52,6 +54,7 @@ import com.github.damontecres.wholphin.ui.cards.BannerCard
|
|||
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
||||
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.cards.StudioCard
|
||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
|
|
@ -122,16 +125,25 @@ fun HomePage(
|
|||
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var position by rememberPosition()
|
||||
HomePageContent(
|
||||
homeRows = homeRows,
|
||||
position = position,
|
||||
onFocusPosition = { position = it },
|
||||
onClickItem = { clickedPosition, item ->
|
||||
|
||||
val onFocusPosition = remember { { it: RowColumn -> position = it } }
|
||||
val onClickItem =
|
||||
remember {
|
||||
{ clickedPosition: RowColumn, item: BaseItem ->
|
||||
position = clickedPosition
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
onLongClickItem = { clickedPosition, item ->
|
||||
}
|
||||
}
|
||||
val onLongClickItem =
|
||||
remember {
|
||||
{ clickedPosition: RowColumn, item: BaseItem ->
|
||||
position = clickedPosition
|
||||
val row =
|
||||
(homeRows.getOrNull(clickedPosition.row) as? HomeRowLoadingState.Success)
|
||||
val canRemoveContinueWatching =
|
||||
row?.rowType is HomeRowConfig.ContinueWatching || row?.rowType is HomeRowConfig.ContinueWatchingCombined
|
||||
val canRemoveNextUp =
|
||||
row?.rowType is HomeRowConfig.NextUp || row?.rowType is HomeRowConfig.ContinueWatchingCombined
|
||||
val dialogItems =
|
||||
buildMoreDialogItemsForHome(
|
||||
context = context,
|
||||
|
|
@ -141,6 +153,8 @@ fun HomePage(
|
|||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
canRemoveNextUp = canRemoveNextUp,
|
||||
canRemoveContinueWatching = canRemoveContinueWatching,
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
navigateTo = viewModel.navigationManager::navigateTo,
|
||||
|
|
@ -158,6 +172,7 @@ fun HomePage(
|
|||
onClickDelete = {
|
||||
showDeleteDialog = RowColumnItem(position, item)
|
||||
},
|
||||
onClickRemoveFromNextUp = viewModel::removeFromNextUp,
|
||||
),
|
||||
)
|
||||
dialog =
|
||||
|
|
@ -166,10 +181,22 @@ fun HomePage(
|
|||
fromLongClick = true,
|
||||
items = dialogItems,
|
||||
)
|
||||
},
|
||||
onClickPlay = { _, item ->
|
||||
}
|
||||
}
|
||||
val onClickPlay =
|
||||
remember {
|
||||
{ _: RowColumn, item: BaseItem ->
|
||||
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
HomePageContent(
|
||||
homeRows = homeRows,
|
||||
position = position,
|
||||
onFocusPosition = onFocusPosition,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = onLongClickItem,
|
||||
onClickPlay = onClickPlay,
|
||||
loadingState = refreshing,
|
||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||
|
|
@ -210,6 +237,8 @@ fun HomePage(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,12 +268,19 @@ fun HomePageContent(
|
|||
},
|
||||
) {
|
||||
val focusedItem =
|
||||
position.let {
|
||||
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
|
||||
remember(homeRows, position) {
|
||||
(homeRows.getOrNull(position.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(
|
||||
position.column,
|
||||
)
|
||||
}
|
||||
|
||||
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
|
||||
val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } }
|
||||
var firstFocused by remember { mutableStateOf(false) }
|
||||
|
||||
val currentPosition by rememberUpdatedState(position)
|
||||
val currentOnFocusPosition by rememberUpdatedState(onFocusPosition)
|
||||
val currentOnClickPlay by rememberUpdatedState(onClickPlay)
|
||||
|
||||
if (takeFocus) {
|
||||
LaunchedEffect(homeRows) {
|
||||
if (!firstFocused && homeRows.isNotEmpty()) {
|
||||
|
|
@ -266,11 +302,6 @@ fun HomePageContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(position) {
|
||||
if (position.row >= 0) {
|
||||
listState.animateScrollToItem(position.row)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(onUpdateBackdrop, focusedItem) {
|
||||
focusedItem?.let { onUpdateBackdrop.invoke(it) }
|
||||
}
|
||||
|
|
@ -280,10 +311,12 @@ fun HomePageContent(
|
|||
|
||||
val density = LocalDensity.current
|
||||
val spaceAbovePx =
|
||||
remember(density) {
|
||||
with(density) {
|
||||
// The size of the row titles & spacing
|
||||
50.dp.toPx()
|
||||
}
|
||||
}
|
||||
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
||||
CompositionLocalProvider(
|
||||
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
|
||||
|
|
@ -329,14 +362,26 @@ fun HomePageContent(
|
|||
ItemRow(
|
||||
title = row.title,
|
||||
items = row.items,
|
||||
onClickItem = { index, item ->
|
||||
onClickItem.invoke(RowColumn(rowIndex, index), item)
|
||||
onClickItem =
|
||||
remember(rowIndex, onClickItem) {
|
||||
{ index, item ->
|
||||
onClickItem.invoke(
|
||||
RowColumn(
|
||||
rowIndex,
|
||||
index,
|
||||
),
|
||||
item,
|
||||
)
|
||||
}
|
||||
},
|
||||
onLongClickItem = { index, item ->
|
||||
onLongClickItem =
|
||||
remember(rowIndex, onLongClickItem) {
|
||||
{ index, item ->
|
||||
onLongClickItem.invoke(
|
||||
RowColumn(rowIndex, index),
|
||||
item,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -346,6 +391,34 @@ fun HomePageContent(
|
|||
.animateItem(),
|
||||
horizontalPadding = viewOptions.spacing.dp,
|
||||
cardContent = { index, item, cardModifier, onClick, onLongClick ->
|
||||
val onFocus =
|
||||
remember(rowIndex, index) {
|
||||
{ isFocused: Boolean ->
|
||||
if (isFocused) {
|
||||
currentOnFocusPosition(
|
||||
RowColumn(
|
||||
rowIndex,
|
||||
index,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val onKey =
|
||||
remember(item) {
|
||||
{ event: androidx.compose.ui.input.key.KeyEvent ->
|
||||
if (isPlayKeyUp(event) && item?.type?.playable == true) {
|
||||
Timber.v("Clicked play on ${item.id}")
|
||||
currentOnClickPlay(
|
||||
currentPosition,
|
||||
item,
|
||||
)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
HomePageCardContent(
|
||||
index = index,
|
||||
item = item,
|
||||
|
|
@ -354,23 +427,8 @@ fun HomePageContent(
|
|||
viewOptions = viewOptions,
|
||||
modifier =
|
||||
cardModifier
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
onFocusPosition?.invoke(
|
||||
RowColumn(rowIndex, index),
|
||||
)
|
||||
}
|
||||
}.onKeyEvent {
|
||||
if (isPlayKeyUp(it) && item?.type?.playable == true) {
|
||||
Timber.v("Clicked play on ${item.id}")
|
||||
onClickPlay.invoke(
|
||||
position,
|
||||
item,
|
||||
)
|
||||
return@onKeyEvent true
|
||||
}
|
||||
return@onKeyEvent false
|
||||
},
|
||||
.onFocusChanged { onFocus(it.isFocused) }
|
||||
.onKeyEvent { onKey(it) },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -503,6 +561,17 @@ fun HomePageCardContent(
|
|||
)
|
||||
}
|
||||
|
||||
BaseItemKind.STUDIO -> {
|
||||
StudioCard(
|
||||
studioId = item.id,
|
||||
name = item.name,
|
||||
imageUrl = item.imageUrlOverride,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier.height(viewOptions.heightDp.dp),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val imageType =
|
||||
remember(item, viewOptions) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.services.DatePlayedService
|
|||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
|
||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||
|
|
@ -39,6 +40,7 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
|
@ -58,6 +60,7 @@ class HomeViewModel
|
|||
private val backdropService: BackdropService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
private val latestNextUpService: LatestNextUpService,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(HomeState.EMPTY)
|
||||
val state: StateFlow<HomeState> = _state
|
||||
|
|
@ -85,7 +88,13 @@ class HomeViewModel
|
|||
val refresh =
|
||||
state.loadingState == LoadingState.Success && state.settings == settings
|
||||
Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}")
|
||||
_state.update { it.copy(settings = settings) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
loadingState = if (refresh) LoadingState.Success else LoadingState.Loading,
|
||||
refreshState = LoadingState.Loading,
|
||||
settings = settings,
|
||||
)
|
||||
}
|
||||
|
||||
val semaphore = Semaphore(4)
|
||||
|
||||
|
|
@ -225,6 +234,19 @@ class HomeViewModel
|
|||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||
|
||||
fun removeFromNextUp(item: BaseItem) {
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
viewModelScope.launchDefault {
|
||||
serverRepository.currentUser.value?.id?.let { userId ->
|
||||
latestNextUpService.removeFromNextUp(userId, item)
|
||||
init()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Timber.w("Item is not an episode %s", item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class HomeState(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.ui.main
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -32,7 +33,6 @@ import androidx.compose.ui.input.key.KeyEventType
|
|||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -232,7 +232,6 @@ fun SearchPage(
|
|||
modifier: Modifier = Modifier,
|
||||
viewModel: SearchViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val movies by viewModel.movies.observeAsState(SearchResult.NoQuery)
|
||||
|
|
@ -377,7 +376,7 @@ fun SearchPage(
|
|||
}
|
||||
}
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.movies),
|
||||
title = R.string.movies,
|
||||
result = movies,
|
||||
rowIndex = MOVIE_ROW,
|
||||
position = position,
|
||||
|
|
@ -387,7 +386,7 @@ fun SearchPage(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.tv_shows),
|
||||
title = R.string.tv_shows,
|
||||
result = series,
|
||||
rowIndex = SERIES_ROW,
|
||||
position = position,
|
||||
|
|
@ -397,7 +396,7 @@ fun SearchPage(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.episodes),
|
||||
title = R.string.episodes,
|
||||
result = episodes,
|
||||
rowIndex = EPISODE_ROW,
|
||||
position = position,
|
||||
|
|
@ -414,12 +413,13 @@ fun SearchPage(
|
|||
},
|
||||
onLongClick = onLongClick,
|
||||
imageHeight = 140.dp,
|
||||
showImageOverlay = true,
|
||||
modifier = mod.padding(horizontal = 8.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.albums),
|
||||
title = R.string.albums,
|
||||
result = albums,
|
||||
rowIndex = ALBUM_ROW,
|
||||
position = position,
|
||||
|
|
@ -437,12 +437,13 @@ fun SearchPage(
|
|||
onLongClick = onLongClick,
|
||||
imageHeight = Cards.heightEpisode,
|
||||
aspectRatio = AspectRatios.SQUARE,
|
||||
showImageOverlay = true,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.artists),
|
||||
title = R.string.artists,
|
||||
result = artists,
|
||||
rowIndex = COLLECTION_ROW,
|
||||
position = position,
|
||||
|
|
@ -460,12 +461,13 @@ fun SearchPage(
|
|||
onLongClick = onLongClick,
|
||||
imageHeight = Cards.heightEpisode,
|
||||
aspectRatio = AspectRatios.SQUARE,
|
||||
showImageOverlay = true,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.songs),
|
||||
title = R.string.songs,
|
||||
result = songs,
|
||||
rowIndex = SONG_ROW,
|
||||
position = position,
|
||||
|
|
@ -483,12 +485,13 @@ fun SearchPage(
|
|||
onLongClick = onLongClick,
|
||||
imageHeight = Cards.heightEpisode,
|
||||
aspectRatio = AspectRatios.SQUARE,
|
||||
showImageOverlay = true,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.collections),
|
||||
title = R.string.collections,
|
||||
result = collections,
|
||||
rowIndex = COLLECTION_ROW,
|
||||
position = position,
|
||||
|
|
@ -498,7 +501,7 @@ fun SearchPage(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
searchResultRow(
|
||||
title = context.getString(R.string.discover),
|
||||
title = R.string.discover,
|
||||
result = seerrResults,
|
||||
rowIndex = SEERR_ROW,
|
||||
position = position,
|
||||
|
|
@ -525,7 +528,7 @@ fun SearchPage(
|
|||
}
|
||||
|
||||
fun LazyListScope.searchResultRow(
|
||||
title: String,
|
||||
@StringRes title: Int,
|
||||
result: SearchResult,
|
||||
rowIndex: Int,
|
||||
position: RowColumn,
|
||||
|
|
@ -549,6 +552,7 @@ fun LazyListScope.searchResultRow(
|
|||
},
|
||||
onLongClick = onLongClick,
|
||||
imageHeight = Cards.height2x3,
|
||||
showImageOverlay = true,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
|
|
@ -557,7 +561,7 @@ fun LazyListScope.searchResultRow(
|
|||
when (val r = result) {
|
||||
is SearchResult.Error -> {
|
||||
SearchResultPlaceholder(
|
||||
title = title,
|
||||
title = stringResource(title),
|
||||
message = r.ex.localizedMessage ?: "Error occurred during search",
|
||||
messageColor = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier,
|
||||
|
|
@ -570,7 +574,7 @@ fun LazyListScope.searchResultRow(
|
|||
|
||||
SearchResult.Searching -> {
|
||||
SearchResultPlaceholder(
|
||||
title = title,
|
||||
title = stringResource(title),
|
||||
message = stringResource(R.string.searching),
|
||||
modifier = modifier,
|
||||
)
|
||||
|
|
@ -579,13 +583,13 @@ fun LazyListScope.searchResultRow(
|
|||
is SearchResult.Success -> {
|
||||
if (r.items.isEmpty()) {
|
||||
SearchResultPlaceholder(
|
||||
title = title,
|
||||
title = stringResource(title),
|
||||
message = stringResource(R.string.no_results),
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
ItemRow(
|
||||
title = title,
|
||||
title = stringResource(title),
|
||||
items = r.items,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = { _, _ -> },
|
||||
|
|
@ -598,13 +602,13 @@ fun LazyListScope.searchResultRow(
|
|||
is SearchResult.SuccessSeerr -> {
|
||||
if (r.items.isEmpty()) {
|
||||
SearchResultPlaceholder(
|
||||
title = title,
|
||||
title = stringResource(title),
|
||||
message = stringResource(R.string.no_results),
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
ItemRow(
|
||||
title = title,
|
||||
title = stringResource(title),
|
||||
items = r.items,
|
||||
onClickItem = { index, item ->
|
||||
onClickPosition.invoke(RowColumn(rowIndex, index))
|
||||
|
|
|
|||
|
|
@ -80,6 +80,16 @@ fun getSupportedRowTypes(library: Library): List<LibraryRowType> {
|
|||
)
|
||||
}
|
||||
|
||||
library.collectionType == CollectionType.TVSHOWS -> {
|
||||
listOf(
|
||||
LibraryRowType.RECENTLY_ADDED,
|
||||
LibraryRowType.RECENTLY_RELEASED,
|
||||
LibraryRowType.SUGGESTIONS,
|
||||
LibraryRowType.GENRES,
|
||||
LibraryRowType.STUDIOS,
|
||||
)
|
||||
}
|
||||
|
||||
supportsSuggestions -> {
|
||||
listOf(
|
||||
LibraryRowType.RECENTLY_ADDED,
|
||||
|
|
@ -124,6 +134,7 @@ enum class LibraryRowType(
|
|||
RECENTLY_RELEASED(R.string.recently_released),
|
||||
SUGGESTIONS(R.string.suggestions),
|
||||
GENRES(R.string.genres),
|
||||
STUDIOS(R.string.studios),
|
||||
TV_CHANNELS(R.string.channels),
|
||||
TV_PROGRAMS(R.string.live_tv),
|
||||
RECENTLY_RECORDED(R.string.recently_recorded),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ fun HomeSettingsGlobal(
|
|||
onClickLoad: () -> Unit,
|
||||
onClickLoadWeb: () -> Unit,
|
||||
onClickReset: () -> Unit,
|
||||
onClickViewNextUp: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val firstFocus: FocusRequester = remember { FocusRequester() }
|
||||
|
|
@ -123,6 +124,20 @@ fun HomeSettingsGlobal(
|
|||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.view_removed_next_up),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_eye),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickViewNextUp,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
|||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog
|
||||
|
|
@ -61,6 +62,7 @@ fun HomeSettingsPage(
|
|||
val backStack = rememberNavBackStack(HomeSettingsDestination.RowList)
|
||||
var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) }
|
||||
var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) }
|
||||
var showRemovedNextUpDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val state by viewModel.state.collectAsState()
|
||||
var position by rememberPosition(0, 0)
|
||||
|
|
@ -284,6 +286,9 @@ fun HomeSettingsPage(
|
|||
addRow(false) { viewModel.resetToDefault() }
|
||||
}
|
||||
},
|
||||
onClickViewNextUp = {
|
||||
showRemovedNextUpDialog = true
|
||||
},
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -340,6 +345,15 @@ fun HomeSettingsPage(
|
|||
},
|
||||
)
|
||||
}
|
||||
if (showRemovedNextUpDialog) {
|
||||
BasicDialog(
|
||||
onDismissRequest = { showRemovedNextUpDialog = false },
|
||||
) {
|
||||
RemovedNextUpContent(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ShowConfirm(
|
||||
|
|
|
|||
|
|
@ -323,6 +323,16 @@ class HomeSettingsViewModel
|
|||
)
|
||||
}
|
||||
|
||||
LibraryRowType.STUDIOS -> {
|
||||
val title =
|
||||
library.name.let { context.getString(R.string.studios_in, it) }
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = title,
|
||||
config = HomeRowConfig.Studios(library.itemId),
|
||||
)
|
||||
}
|
||||
|
||||
LibraryRowType.SUGGESTIONS -> {
|
||||
val title =
|
||||
library.name.let { context.getString(R.string.suggestions_for, it) }
|
||||
|
|
@ -414,7 +424,7 @@ class HomeSettingsViewModel
|
|||
config =
|
||||
HomeRowConfig.ByParent(
|
||||
parentId = parent.id,
|
||||
recursive = true,
|
||||
recursive = false,
|
||||
),
|
||||
)
|
||||
updateState {
|
||||
|
|
@ -700,6 +710,10 @@ class HomeSettingsViewModel
|
|||
it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize))
|
||||
}
|
||||
|
||||
is HomeRowConfig.Studios -> {
|
||||
it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize))
|
||||
}
|
||||
|
||||
is HomeRowConfig.GetItems -> {
|
||||
it.config
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
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 androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.ListItemDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDateTime
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class RemovedNextUpContentViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val latestNextUpService: LatestNextUpService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
) : ViewModel() {
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val _state = MutableStateFlow(RemovedNextUpState())
|
||||
val state: StateFlow<RemovedNextUpState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launchDefault {
|
||||
serverRepository.currentUser.asFlow().collectLatest { user ->
|
||||
_state.update { RemovedNextUpState() }
|
||||
if (user == null) {
|
||||
return@collectLatest
|
||||
}
|
||||
try {
|
||||
val removed = latestNextUpService.getRemovedFromNextUp(user.id)
|
||||
val series = mutableListOf<RemovedItem>()
|
||||
removed.keys.chunked(50).forEach { ids ->
|
||||
val results =
|
||||
api.itemsApi
|
||||
.getItems(
|
||||
userId = user.id,
|
||||
ids = ids,
|
||||
).toBaseItems(api, false)
|
||||
results.forEach {
|
||||
val imageUrl = imageUrlService.getItemImageUrl(it, ImageType.PRIMARY)
|
||||
series.add(RemovedItem(it, imageUrl, removed[it.id]!!))
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(loading = DataLoadingState.Success(series)) }
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching removed series")
|
||||
_state.update { it.copy(loading = DataLoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(item: RemovedItem) {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
viewModelScope.launchDefault {
|
||||
mutex.withLock {
|
||||
_state.update { it.copy(removedEnabled = false) }
|
||||
try {
|
||||
latestNextUpService.allowSeriesRemovedFromNextUp(user.id, item.series.id)
|
||||
val newItems =
|
||||
(_state.value.loading as? DataLoadingState.Success<List<RemovedItem>>)
|
||||
?.data
|
||||
?.toMutableList()
|
||||
?.apply {
|
||||
removeIf { it.series.id == item.series.id }
|
||||
}
|
||||
val loading =
|
||||
if (newItems != null) {
|
||||
DataLoadingState.Success(newItems)
|
||||
} else {
|
||||
DataLoadingState.Error("Error occurred")
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = loading,
|
||||
removedEnabled = true,
|
||||
)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error removing %s from removed next up", item.series.id)
|
||||
} finally {
|
||||
_state.update { it.copy(removedEnabled = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
data class RemovedItem(
|
||||
val series: BaseItem,
|
||||
val imageUrl: String?,
|
||||
val datetime: LocalDateTime,
|
||||
)
|
||||
|
||||
data class RemovedNextUpState(
|
||||
val loading: DataLoadingState<List<RemovedItem>> = DataLoadingState.Pending,
|
||||
val removedEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RemovedNextUpContent(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RemovedNextUpContentViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = "Removed from next up",
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
)
|
||||
when (val s = state.loading) {
|
||||
DataLoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
-> {
|
||||
LoadingPage(Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(s, Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
is DataLoadingState.Success<List<RemovedItem>> -> {
|
||||
if (s.data.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_results),
|
||||
)
|
||||
} else {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
items(s.data, key = { it.series.id }) { item ->
|
||||
RemovedListItem(
|
||||
item = item,
|
||||
removedEnabled = state.removedEnabled,
|
||||
onClickRemove =
|
||||
remember {
|
||||
{ viewModel.remove(item) }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemovedListItem(
|
||||
item: RemovedItem,
|
||||
removedEnabled: Boolean,
|
||||
onClickRemove: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier,
|
||||
) {
|
||||
ListItem(
|
||||
selected = false,
|
||||
onClick = {},
|
||||
leadingContent = {
|
||||
AsyncImage(
|
||||
model = item.imageUrl,
|
||||
contentDescription = item.series.title,
|
||||
modifier = Modifier.height(80.dp),
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = item.series.title ?: item.series.id.toString(),
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = formatDateTime(item.datetime),
|
||||
)
|
||||
},
|
||||
scale = ListItemDefaults.scale(focusedScale = 1f),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Button(
|
||||
onClick = onClickRemove,
|
||||
enabled = removedEnabled,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "delete",
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
|
|||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
||||
import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
|
@ -97,6 +99,7 @@ sealed class Destination(
|
|||
@Serializable
|
||||
data class FilteredCollection(
|
||||
val itemId: UUID,
|
||||
val parentType: BaseItemKind,
|
||||
val filter: CollectionFolderFilter,
|
||||
val recursive: Boolean,
|
||||
) : Destination(false)
|
||||
|
|
@ -129,6 +132,11 @@ sealed class Destination(
|
|||
val item: DiscoverItem,
|
||||
) : Destination(false)
|
||||
|
||||
data class DiscoverMoreResult(
|
||||
val type: DiscoverRequestType,
|
||||
val startIndex: Int = SEERR_PAGE_SIZE,
|
||||
) : Destination(false)
|
||||
|
||||
@Serializable
|
||||
data object NowPlaying : Destination(true)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
|
||||
import com.github.damontecres.wholphin.data.filter.DefaultForStudiosFilterOptions
|
||||
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.ItemGrid
|
||||
|
|
@ -35,6 +36,7 @@ import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage
|
|||
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRequestGrid
|
||||
import com.github.damontecres.wholphin.ui.main.HomePage
|
||||
import com.github.damontecres.wholphin.ui.main.SearchPage
|
||||
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
|
||||
|
|
@ -251,7 +253,12 @@ fun DestinationContent(
|
|||
recursive = destination.recursive,
|
||||
usePosters = true,
|
||||
playEnabled = true, // TODO only genres use this currently, so might need to change in future
|
||||
filterOptions = DefaultForGenresFilterOptions,
|
||||
filterOptions =
|
||||
when (destination.parentType) {
|
||||
BaseItemKind.GENRE -> DefaultForGenresFilterOptions
|
||||
BaseItemKind.STUDIO -> DefaultForStudiosFilterOptions
|
||||
else -> throw IllegalArgumentException("Unsupported parentType ${destination.parentType}")
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -353,6 +360,14 @@ fun DestinationContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Destination.DiscoverMoreResult -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
DiscoverRequestGrid(
|
||||
destination = destination,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ 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
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -55,20 +54,26 @@ import com.github.damontecres.wholphin.preferences.advancedPreferences
|
|||
import com.github.damontecres.wholphin.preferences.basicPreferences
|
||||
import com.github.damontecres.wholphin.preferences.screensaverPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
|
||||
import com.github.damontecres.wholphin.services.Release
|
||||
import com.github.damontecres.wholphin.services.SeerrConnectionStatus
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playOnClickSound
|
||||
import com.github.damontecres.wholphin.ui.playSoundOnFocus
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||
import com.github.damontecres.wholphin.ui.setup.ReleaseNotes
|
||||
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -93,6 +98,7 @@ fun PreferencesContent(
|
|||
val currentUser by viewModel.currentUser.observeAsState()
|
||||
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
|
||||
var showPinFlow by remember { mutableStateOf(false) }
|
||||
var showVersionDialog by remember { mutableStateOf(false) }
|
||||
|
||||
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
|
||||
val seerrConnection by viewModel.seerrConnection.collectAsState()
|
||||
|
|
@ -252,15 +258,13 @@ fun PreferencesContent(
|
|||
}
|
||||
when (pref) {
|
||||
AppPreference.InstalledVersion -> {
|
||||
var clickCount by remember { mutableIntStateOf(0) }
|
||||
ClickPreference(
|
||||
title = stringResource(R.string.installed_version),
|
||||
onClick = {
|
||||
if (movementSounds) playOnClickSound(context)
|
||||
if (clickCount++ >= 2) {
|
||||
clickCount = 0
|
||||
showVersionDialog = true
|
||||
},
|
||||
onLongClick = {
|
||||
viewModel.navigationManager.navigateTo(Destination.Debug)
|
||||
}
|
||||
},
|
||||
summary = installedVersion.toString(),
|
||||
interactionSource = interactionSource,
|
||||
|
|
@ -615,6 +619,33 @@ fun PreferencesContent(
|
|||
},
|
||||
)
|
||||
}
|
||||
if (showVersionDialog) {
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.fetchReleaseNotes()
|
||||
}
|
||||
val release by viewModel.releaseNotes.collectAsState()
|
||||
ScrollableDialog(
|
||||
onDismissRequest = { showVersionDialog = false },
|
||||
) {
|
||||
item {
|
||||
when (val r = release) {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(message = "Error", exception = r.exception)
|
||||
}
|
||||
|
||||
DataLoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
-> {
|
||||
LoadingPage()
|
||||
}
|
||||
|
||||
is DataLoadingState.Success<Release> -> {
|
||||
ReleaseNotes(r.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -11,10 +11,13 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles
|
|||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.Release
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
|
|
@ -22,9 +25,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -42,6 +47,7 @@ class PreferencesViewModel
|
|||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val deviceInfo: DeviceInfo,
|
||||
private val clientInfo: ClientInfo,
|
||||
private val updateChecker: UpdateChecker,
|
||||
) : ViewModel(),
|
||||
RememberTabManager by rememberTabManager {
|
||||
val currentUser get() = serverRepository.currentUser
|
||||
|
|
@ -51,6 +57,8 @@ class PreferencesViewModel
|
|||
private val _quickConnectStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
||||
val quickConnectStatus: StateFlow<LoadingState> = _quickConnectStatus
|
||||
|
||||
val releaseNotes = MutableStateFlow<DataLoadingState<Release>>(DataLoadingState.Pending)
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
|
|
@ -99,6 +107,23 @@ class PreferencesViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun fetchReleaseNotes() {
|
||||
viewModelScope.launchIO {
|
||||
releaseNotes.update { DataLoadingState.Loading }
|
||||
try {
|
||||
val release = updateChecker.getRelease(updateChecker.getInstalledVersion())
|
||||
if (release != null) {
|
||||
releaseNotes.update { DataLoadingState.Success(release) }
|
||||
} else {
|
||||
releaseNotes.update { DataLoadingState.Error("Release not found") }
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching release")
|
||||
releaseNotes.update { DataLoadingState.Error(ex) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
suspend fun resetSubtitleSettings(appPreferences: DataStore<AppPreferences>) {
|
||||
appPreferences.updateData {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ import com.github.damontecres.wholphin.services.Release
|
|||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||
|
|
@ -66,6 +65,7 @@ import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
|||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.Version
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -272,15 +272,7 @@ fun InstallUpdatePageContent(
|
|||
},
|
||||
) {
|
||||
item {
|
||||
Markdown(
|
||||
(release.notes.joinToString("\n\n") + (release.body ?: ""))
|
||||
.replace(
|
||||
Regex("https://github.com/damontecres/\\w+/pull/(\\d+)"),
|
||||
"#$1",
|
||||
)
|
||||
// Remove the last line for full changelog since its just a link
|
||||
.replace(Regex("\\*\\*Full Changelog\\*\\*.*"), ""),
|
||||
)
|
||||
ReleaseNotes(release)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
|
|
@ -317,6 +309,25 @@ fun InstallUpdatePageContent(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReleaseNotes(
|
||||
release: Release,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Markdown(
|
||||
content = release.content,
|
||||
typography =
|
||||
markdownTypography(
|
||||
h1 = MaterialTheme.typography.headlineLarge,
|
||||
h2 = MaterialTheme.typography.headlineMedium,
|
||||
h3 = MaterialTheme.typography.headlineSmall,
|
||||
text = MaterialTheme.typography.bodySmall,
|
||||
code = MaterialTheme.typography.bodySmall,
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DownloadDialog(
|
||||
contentLength: Long,
|
||||
|
|
@ -392,6 +403,7 @@ private fun InstallUpdatePageContentPreview() {
|
|||
downloadUrl = "https://url",
|
||||
publishedAt = null,
|
||||
body =
|
||||
"## Header 2\n" +
|
||||
"Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " +
|
||||
"ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " +
|
||||
"tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " +
|
||||
|
|
@ -399,6 +411,7 @@ private fun InstallUpdatePageContentPreview() {
|
|||
"Iaculis massa nisl malesuada lacinia integer nunc posuere. " +
|
||||
"Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora " +
|
||||
"torquent per conubia nostra inceptos himenaeos.\n\n" +
|
||||
"### Header 3\n" +
|
||||
"Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " +
|
||||
"ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " +
|
||||
"tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " +
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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.StudioFilter
|
||||
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
|
||||
import com.github.damontecres.wholphin.data.filter.YearFilter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -16,7 +17,9 @@ 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.studiosApi
|
||||
import org.jellyfin.sdk.api.client.extensions.yearsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import timber.log.Timber
|
||||
|
|
@ -47,6 +50,16 @@ object FilterUtils {
|
|||
.map { FilterValueOption(it.name ?: "", it.id) }
|
||||
}
|
||||
|
||||
StudioFilter -> {
|
||||
api.studiosApi
|
||||
.getStudios(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
includeItemTypes = listOf(BaseItemKind.SERIES),
|
||||
).content.items
|
||||
.map { FilterValueOption(it.name ?: "", it.id) }
|
||||
}
|
||||
|
||||
FavoriteFilter,
|
||||
PlayedFilter,
|
||||
-> {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,10 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.Response
|
||||
import org.jellyfin.sdk.api.client.extensions.genresApi
|
||||
|
|
@ -20,6 +12,7 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi
|
|||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.api.client.extensions.personsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.studiosApi
|
||||
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
|
|
@ -32,114 +25,40 @@ import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
|||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.function.Predicate
|
||||
|
||||
/**
|
||||
* Handles paging for an API request. You must call [init] prior to use.
|
||||
*
|
||||
* Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope].
|
||||
* Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched.
|
||||
*
|
||||
* Finally, items are cached allow for backward and forward scrolling.
|
||||
* A [RequestPager] for Jellyfin server queries
|
||||
*/
|
||||
class ApiRequestPager<T>(
|
||||
val api: ApiClient,
|
||||
val request: T,
|
||||
val requestHandler: RequestHandler<T>,
|
||||
private val scope: CoroutineScope,
|
||||
private val pageSize: Int = DEFAULT_PAGE_SIZE,
|
||||
scope: CoroutineScope,
|
||||
pageSize: Int = DEFAULT_PAGE_SIZE,
|
||||
cacheSize: Long = 8,
|
||||
private val useSeriesForPrimary: Boolean = false,
|
||||
) : AbstractList<BaseItem?>(),
|
||||
BlockingList<BaseItem?> {
|
||||
private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf()))
|
||||
private var totalCount by mutableIntStateOf(-1)
|
||||
private val mutex = Mutex()
|
||||
private val cachedPages =
|
||||
CacheBuilder
|
||||
.newBuilder()
|
||||
.maximumSize(cacheSize)
|
||||
.build<Int, MutableList<BaseItem>>()
|
||||
) : RequestPager<BaseItem>(scope, pageSize, cacheSize) {
|
||||
override suspend fun init(initialPosition: Int): ApiRequestPager<T> = super.init(initialPosition) as ApiRequestPager<T>
|
||||
|
||||
suspend fun init(initialPosition: Int = 0): ApiRequestPager<T> {
|
||||
if (totalCount < 0) {
|
||||
fetchPageBlocking(initialPosition, true)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override operator fun get(index: Int): BaseItem? {
|
||||
if (index in 0..<totalCount) {
|
||||
val item = items[index]
|
||||
if (item == null) {
|
||||
fetchPage(index)
|
||||
}
|
||||
return item
|
||||
} else {
|
||||
throw IndexOutOfBoundsException("$index of $totalCount")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getBlocking(index: Int): BaseItem? {
|
||||
if (index in 0..<totalCount) {
|
||||
val item = items[index]
|
||||
if (item == null) {
|
||||
fetchPageBlocking(index, false)
|
||||
return items[index]
|
||||
}
|
||||
return item
|
||||
} else {
|
||||
throw IndexOutOfBoundsException("$index of $totalCount")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun indexOfBlocking(predicate: Predicate<BaseItem?>): Int {
|
||||
init()
|
||||
for (i in 0 until totalCount) {
|
||||
val currentItem = getBlocking(i)
|
||||
if (currentItem != null && predicate.test(currentItem)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
override val size: Int
|
||||
get() = totalCount
|
||||
|
||||
private fun fetchPage(position: Int): Job =
|
||||
scope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
fetchPageBlocking(position, false)
|
||||
}
|
||||
|
||||
private suspend fun fetchPageBlocking(
|
||||
position: Int,
|
||||
setTotalCount: Boolean,
|
||||
) {
|
||||
mutex.withLock {
|
||||
val pageNumber = position / pageSize
|
||||
if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) {
|
||||
if (DEBUG) Timber.v("fetchPage: $pageNumber")
|
||||
override suspend fun fetchPage(
|
||||
pageNumber: Int,
|
||||
includeTotalCount: Boolean,
|
||||
): QueryResult<BaseItem> {
|
||||
val newRequest =
|
||||
requestHandler.prepare(
|
||||
request,
|
||||
pageNumber * pageSize,
|
||||
pageSize,
|
||||
setTotalCount,
|
||||
includeTotalCount,
|
||||
)
|
||||
val result = requestHandler.execute(api, newRequest).content
|
||||
if (setTotalCount) {
|
||||
totalCount = result.totalRecordCount.coerceAtLeast(0)
|
||||
}
|
||||
val data = mutableListOf<BaseItem>()
|
||||
result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) }
|
||||
cachedPages.put(pageNumber, data)
|
||||
items = ItemList(totalCount, pageSize, cachedPages.asMap())
|
||||
}
|
||||
}
|
||||
result.items.forEach { data.add(BaseItem(it, useSeriesForPrimary)) }
|
||||
return QueryResult(data, result.totalRecordCount)
|
||||
}
|
||||
|
||||
suspend fun refreshItem(
|
||||
|
|
@ -161,7 +80,7 @@ class ApiRequestPager<T>(
|
|||
if (page != null && index in page.indices) {
|
||||
page[index] = item
|
||||
cachedPages.put(pageNumber, page)
|
||||
items = ItemList(totalCount, pageSize, cachedPages.asMap())
|
||||
items = ItemList(size, pageSize, cachedPages.asMap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -181,39 +100,10 @@ class ApiRequestPager<T>(
|
|||
}
|
||||
fetchPageBlocking(position, true)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEBUG = false
|
||||
}
|
||||
|
||||
class ItemList<T>(
|
||||
val size: Int,
|
||||
val pageSize: Int,
|
||||
val pages: Map<Int, List<T>>,
|
||||
) {
|
||||
operator fun get(position: Int): T? {
|
||||
val page = position / pageSize
|
||||
val data = pages[page]
|
||||
if (data != null) {
|
||||
val index = position % pageSize
|
||||
if (index in data.indices) {
|
||||
return data[index]
|
||||
} else {
|
||||
// This can happen when items are removed while scrolling
|
||||
Timber.w(
|
||||
"Index $index not in data: position=$position, data.size=${data.size}",
|
||||
)
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies how the [ApiRequestPager] should prepare and execute API calls
|
||||
* Specifies how a [RequestPager] should prepare and execute API calls
|
||||
*/
|
||||
interface RequestHandler<T> {
|
||||
/**
|
||||
|
|
@ -235,6 +125,7 @@ interface RequestHandler<T> {
|
|||
): Response<BaseItemDtoQueryResult>
|
||||
}
|
||||
|
||||
@Serializable
|
||||
val GetItemsRequestHandler =
|
||||
object : RequestHandler<GetItemsRequest> {
|
||||
override fun prepare(
|
||||
|
|
@ -255,6 +146,7 @@ val GetItemsRequestHandler =
|
|||
): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
val GetEpisodesRequestHandler =
|
||||
object : RequestHandler<GetEpisodesRequest> {
|
||||
override fun prepare(
|
||||
|
|
@ -274,6 +166,7 @@ val GetEpisodesRequestHandler =
|
|||
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
val GetResumeItemsRequestHandler =
|
||||
object : RequestHandler<GetResumeItemsRequest> {
|
||||
override fun prepare(
|
||||
|
|
@ -294,6 +187,7 @@ val GetResumeItemsRequestHandler =
|
|||
): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
val GetNextUpRequestHandler =
|
||||
object : RequestHandler<GetNextUpRequest> {
|
||||
override fun prepare(
|
||||
|
|
@ -314,6 +208,7 @@ val GetNextUpRequestHandler =
|
|||
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
val GetSuggestionsRequestHandler =
|
||||
object : RequestHandler<GetSuggestionsRequest> {
|
||||
override fun prepare(
|
||||
|
|
@ -334,6 +229,7 @@ val GetSuggestionsRequestHandler =
|
|||
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
val GetPlaylistItemsRequestHandler =
|
||||
object : RequestHandler<GetPlaylistItemsRequest> {
|
||||
override fun prepare(
|
||||
|
|
@ -353,6 +249,7 @@ val GetPlaylistItemsRequestHandler =
|
|||
): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
val GetGenresRequestHandler =
|
||||
object : RequestHandler<GetGenresRequest> {
|
||||
override fun prepare(
|
||||
|
|
@ -392,6 +289,7 @@ val GetProgramsDtoHandler =
|
|||
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
val GetPersonsHandler =
|
||||
object : RequestHandler<GetPersonsRequest> {
|
||||
override fun prepare(
|
||||
|
|
@ -410,3 +308,23 @@ val GetPersonsHandler =
|
|||
request: GetPersonsRequest,
|
||||
): Response<BaseItemDtoQueryResult> = api.personsApi.getPersons((request))
|
||||
}
|
||||
|
||||
val GetStudiosRequestHandler =
|
||||
object : RequestHandler<GetStudiosRequest> {
|
||||
override fun prepare(
|
||||
request: GetStudiosRequest,
|
||||
startIndex: Int,
|
||||
limit: Int,
|
||||
enableTotalRecordCount: Boolean,
|
||||
): GetStudiosRequest =
|
||||
request.copy(
|
||||
startIndex = startIndex,
|
||||
limit = limit,
|
||||
enableTotalRecordCount = enableTotalRecordCount,
|
||||
)
|
||||
|
||||
override suspend fun execute(
|
||||
api: ApiClient,
|
||||
request: GetStudiosRequest,
|
||||
): Response<BaseItemDtoQueryResult> = api.studiosApi.getStudios(request)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieResult
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvResult
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.services.SeerrApi
|
||||
import com.github.damontecres.wholphin.services.SeerrSearchResult
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
/**
|
||||
* A [RequestPager] for Seerr server queries
|
||||
*/
|
||||
class DiscoverRequestPager<T>(
|
||||
private val api: SeerrApi,
|
||||
val requestHandler: DiscoverRequestHandler<T>,
|
||||
val transform: suspend (T) -> DiscoverItem,
|
||||
scope: CoroutineScope,
|
||||
pageSize: Int = SEERR_PAGE_SIZE,
|
||||
cacheSize: Long = 16,
|
||||
) : RequestPager<DiscoverItem>(scope, pageSize, cacheSize) {
|
||||
override suspend fun init(initialPosition: Int): DiscoverRequestPager<T> = super.init(initialPosition) as DiscoverRequestPager<T>
|
||||
|
||||
override suspend fun fetchPage(
|
||||
pageNumber: Int,
|
||||
includeTotalCount: Boolean,
|
||||
): QueryResult<DiscoverItem> {
|
||||
val result = requestHandler.execute(api, pageNumber + 1) // Seerr pages are 1-indexed
|
||||
val transformed = result.items.map { transform.invoke(it) }
|
||||
return QueryResult(transformed, result.totalCount)
|
||||
}
|
||||
}
|
||||
|
||||
const val SEERR_PAGE_SIZE = 20
|
||||
|
||||
enum class DiscoverRequestType(
|
||||
@param:StringRes val stringRes: Int,
|
||||
) {
|
||||
DISCOVER_TV(R.string.discover_tv),
|
||||
DISCOVER_MOVIES(R.string.discover_movies),
|
||||
TRENDING(R.string.trending),
|
||||
UPCOMING_TV(R.string.upcoming_tv),
|
||||
UPCOMING_MOVIES(R.string.upcoming_movies),
|
||||
UNKNOWN(R.string.unknown),
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies how a [RequestPager] should prepare and execute API calls
|
||||
*/
|
||||
interface DiscoverRequestHandler<T> {
|
||||
suspend fun execute(
|
||||
api: SeerrApi,
|
||||
pageNumber: Int,
|
||||
): QueryResult<T>
|
||||
}
|
||||
|
||||
val DiscoverTvRequestHandler =
|
||||
object : DiscoverRequestHandler<TvResult> {
|
||||
override suspend fun execute(
|
||||
api: SeerrApi,
|
||||
pageNumber: Int,
|
||||
): QueryResult<TvResult> =
|
||||
api.api.searchApi.discoverTvGet(page = pageNumber).let {
|
||||
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
val DiscoverMovieRequestHandler =
|
||||
object : DiscoverRequestHandler<MovieResult> {
|
||||
override suspend fun execute(
|
||||
api: SeerrApi,
|
||||
pageNumber: Int,
|
||||
): QueryResult<MovieResult> =
|
||||
api.api.searchApi.discoverMoviesGet(page = pageNumber).let {
|
||||
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
val TrendingRequestHandler =
|
||||
object : DiscoverRequestHandler<SeerrSearchResult> {
|
||||
override suspend fun execute(
|
||||
api: SeerrApi,
|
||||
pageNumber: Int,
|
||||
): QueryResult<SeerrSearchResult> =
|
||||
api.api.searchApi.discoverTrendingGet(page = pageNumber).let {
|
||||
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
val UpcomingTvRequestHandler =
|
||||
object : DiscoverRequestHandler<TvResult> {
|
||||
override suspend fun execute(
|
||||
api: SeerrApi,
|
||||
pageNumber: Int,
|
||||
): QueryResult<TvResult> =
|
||||
api.api.searchApi.discoverTvUpcomingGet(page = pageNumber).let {
|
||||
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
val UpcomingMovieRequestHandler =
|
||||
object : DiscoverRequestHandler<MovieResult> {
|
||||
override suspend fun execute(
|
||||
api: SeerrApi,
|
||||
pageNumber: Int,
|
||||
): QueryResult<MovieResult> =
|
||||
api.api.searchApi.discoverMoviesUpcomingGet(page = pageNumber).let {
|
||||
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
|
||||
/**
|
||||
|
|
@ -64,6 +65,7 @@ sealed interface HomeRowLoadingState {
|
|||
override val title: String,
|
||||
val items: List<BaseItem?>,
|
||||
val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
val rowType: HomeRowConfig? = null,
|
||||
) : HomeRowLoadingState
|
||||
|
||||
data class Error(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor
|
|||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
object LocalDateSerializer : KSerializer<LocalDate> {
|
||||
|
|
@ -22,3 +23,20 @@ object LocalDateSerializer : KSerializer<LocalDate> {
|
|||
override fun deserialize(decoder: Decoder): LocalDate =
|
||||
decoder.decodeString().let { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }
|
||||
}
|
||||
|
||||
object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
|
||||
override val descriptor: SerialDescriptor
|
||||
get() = SerialDescriptor("LocalDateTime", String.serializer().descriptor)
|
||||
|
||||
override fun serialize(
|
||||
encoder: Encoder,
|
||||
value: LocalDateTime,
|
||||
) {
|
||||
encoder.encodeString(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(value))
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): LocalDateTime =
|
||||
decoder
|
||||
.decodeString()
|
||||
.let { LocalDateTime.parse(it, DateTimeFormatter.ISO_LOCAL_DATE_TIME) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import timber.log.Timber
|
||||
import java.util.function.Predicate
|
||||
|
||||
/**
|
||||
* Handles paging for an API request. You must call [init] prior to use.
|
||||
*
|
||||
* Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope].
|
||||
* Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched.
|
||||
*
|
||||
* Finally, items are cached allow for backward and forward scrolling.
|
||||
*/
|
||||
abstract class RequestPager<T>(
|
||||
protected val scope: CoroutineScope,
|
||||
protected val pageSize: Int = DEFAULT_PAGE_SIZE,
|
||||
cacheSize: Long = 8,
|
||||
) : AbstractList<T?>(),
|
||||
BlockingList<T?> {
|
||||
protected var totalCount by mutableIntStateOf(-1)
|
||||
protected var items by mutableStateOf(ItemList<T>(0, pageSize, mapOf()))
|
||||
protected val mutex = Mutex()
|
||||
protected val cachedPages =
|
||||
CacheBuilder
|
||||
.newBuilder()
|
||||
.maximumSize(cacheSize)
|
||||
.build<Int, MutableList<T>>()
|
||||
|
||||
open suspend fun init(initialPosition: Int = 0): RequestPager<T> {
|
||||
if (totalCount < 0) {
|
||||
fetchPageBlocking(initialPosition, true)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override operator fun get(index: Int): T? {
|
||||
if (index in 0..<totalCount) {
|
||||
val item = items[index]
|
||||
if (item == null) {
|
||||
fetchPage(index)
|
||||
}
|
||||
return item
|
||||
} else {
|
||||
throw IndexOutOfBoundsException("$index of $totalCount")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getBlocking(index: Int): T? {
|
||||
if (index in 0..<totalCount) {
|
||||
val item = items[index]
|
||||
if (item == null) {
|
||||
fetchPageBlocking(index, false)
|
||||
return items[index]
|
||||
}
|
||||
return item
|
||||
} else {
|
||||
throw IndexOutOfBoundsException("$index of $totalCount")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun indexOfBlocking(predicate: Predicate<T?>): Int {
|
||||
init(0)
|
||||
for (i in 0 until totalCount) {
|
||||
val currentItem = getBlocking(i)
|
||||
if (currentItem != null && predicate.test(currentItem)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
override val size: Int
|
||||
get() = totalCount
|
||||
|
||||
private fun fetchPage(position: Int): Job =
|
||||
scope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
fetchPageBlocking(position, false)
|
||||
}
|
||||
|
||||
protected suspend fun fetchPageBlocking(
|
||||
position: Int,
|
||||
setTotalCount: Boolean,
|
||||
) {
|
||||
mutex.withLock {
|
||||
val pageNumber = position / pageSize
|
||||
if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) {
|
||||
if (DEBUG) Timber.v("fetchPage: $pageNumber")
|
||||
val result = fetchPage(pageNumber, setTotalCount)
|
||||
if (setTotalCount && result.totalCount != null) {
|
||||
totalCount = result.totalCount.coerceAtLeast(0)
|
||||
}
|
||||
cachedPages.put(pageNumber, result.items.toMutableList())
|
||||
items = ItemList(totalCount, pageSize, cachedPages.asMap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract suspend fun fetchPage(
|
||||
pageNumber: Int,
|
||||
includeTotalCount: Boolean,
|
||||
): QueryResult<T>
|
||||
|
||||
companion object {
|
||||
internal const val DEBUG = false
|
||||
}
|
||||
|
||||
class ItemList<T>(
|
||||
val size: Int,
|
||||
val pageSize: Int,
|
||||
val pages: Map<Int, List<T>>,
|
||||
) {
|
||||
operator fun get(position: Int): T? {
|
||||
val page = position / pageSize
|
||||
val data = pages[page]
|
||||
if (data != null) {
|
||||
val index = position % pageSize
|
||||
if (index in data.indices) {
|
||||
return data[index]
|
||||
} else {
|
||||
// This can happen when items are removed while scrolling
|
||||
Timber.w(
|
||||
"Index $index not in data: position=$position, data.size=${data.size}",
|
||||
)
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class QueryResult<T>(
|
||||
val items: List<T>,
|
||||
val totalCount: Int?,
|
||||
)
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
LOCAL_PATH:= $(call my-dir)
|
||||
|
||||
ifeq ($(TARGET_ARCH_ABI),armeabi-v7a)
|
||||
PREFIX = $(PREFIX32)
|
||||
endif
|
||||
ifeq ($(TARGET_ARCH_ABI),arm64-v8a)
|
||||
PREFIX = $(PREFIX64)
|
||||
endif
|
||||
ifeq ($(TARGET_ARCH_ABI),x86_64)
|
||||
PREFIX = $(PREFIX_X64)
|
||||
endif
|
||||
ifeq ($(TARGET_ARCH_ABI),x86)
|
||||
PREFIX = $(PREFIX_X86)
|
||||
endif
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := libswresample
|
||||
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
|
||||
include $(PREBUILT_SHARED_LIBRARY)
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := libpostproc
|
||||
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
|
||||
# only include if library file exists
|
||||
ifneq (,$(wildcard $(LOCAL_SRC_FILES)))
|
||||
include $(PREBUILT_SHARED_LIBRARY)
|
||||
endif
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := libavutil
|
||||
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
|
||||
include $(PREBUILT_SHARED_LIBRARY)
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := libavcodec
|
||||
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
|
||||
include $(PREBUILT_SHARED_LIBRARY)
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := libavformat
|
||||
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
|
||||
include $(PREBUILT_SHARED_LIBRARY)
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := libswscale
|
||||
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
|
||||
include $(PREBUILT_SHARED_LIBRARY)
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := libavfilter
|
||||
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
|
||||
include $(PREBUILT_SHARED_LIBRARY)
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := libavdevice
|
||||
LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
|
||||
LOCAL_EXPORT_C_INCLUDES := $(PREFIX)/include
|
||||
include $(PREBUILT_SHARED_LIBRARY)
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := libmpv
|
||||
LOCAL_SRC_FILES := $(PREFIX)/lib/libmpv.so
|
||||
LOCAL_EXPORT_C_INCLUDES := $(PREFIX)/include
|
||||
include $(PREBUILT_SHARED_LIBRARY)
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
|
||||
LOCAL_MODULE := libplayer
|
||||
LOCAL_CFLAGS := -Werror
|
||||
LOCAL_CPPFLAGS += -std=c++11
|
||||
LOCAL_SRC_FILES := \
|
||||
main.cpp \
|
||||
render.cpp \
|
||||
log.cpp \
|
||||
jni_utils.cpp \
|
||||
property.cpp \
|
||||
event.cpp \
|
||||
thumbnail.cpp
|
||||
LOCAL_LDLIBS := -llog -lGLESv3 -lEGL -latomic
|
||||
LOCAL_SHARED_LIBRARIES := swscale avcodec mpv
|
||||
|
||||
include $(BUILD_SHARED_LIBRARY)
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
APP_ABI :=
|
||||
ifneq ($(PREFIX32),)
|
||||
APP_ABI += armeabi-v7a
|
||||
endif
|
||||
ifneq ($(PREFIX64),)
|
||||
APP_ABI += arm64-v8a
|
||||
endif
|
||||
ifneq ($(PREFIX_X64),)
|
||||
APP_ABI += x86_64
|
||||
endif
|
||||
ifneq ($(PREFIX_X86),)
|
||||
APP_ABI += x86
|
||||
endif
|
||||
|
||||
APP_PLATFORM := android-21
|
||||
APP_STL := c++_shared
|
||||
APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# JNI bindings for libmpv
|
||||
|
||||
The files in this directory are copied from https://github.com/mpv-android/mpv-android/tree/master/app/src/main/jni
|
||||
|
||||
## License & copyright
|
||||
|
||||
The files are copyrighted and used under MIT license below:
|
||||
|
||||
Copyright (c) 2016 Ilya Zhuravlev
|
||||
Copyright (c) 2016 sfan5 <sfan5@live.de>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
#include <jni.h>
|
||||
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
static void sendPropertyUpdateToJava(JNIEnv *env, mpv_event_property *prop)
|
||||
{
|
||||
jstring jprop = env->NewStringUTF(prop->name);
|
||||
jstring jvalue = NULL;
|
||||
switch (prop->format) {
|
||||
case MPV_FORMAT_NONE:
|
||||
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_S, jprop);
|
||||
break;
|
||||
case MPV_FORMAT_FLAG:
|
||||
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sb, jprop,
|
||||
(jboolean) (*(int*)prop->data != 0));
|
||||
break;
|
||||
case MPV_FORMAT_INT64:
|
||||
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sl, jprop,
|
||||
(jlong) *(int64_t*)prop->data);
|
||||
break;
|
||||
case MPV_FORMAT_DOUBLE:
|
||||
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sd, jprop,
|
||||
(jdouble) *(double*)prop->data);
|
||||
break;
|
||||
case MPV_FORMAT_STRING:
|
||||
jvalue = env->NewStringUTF(*(const char**)prop->data);
|
||||
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_SS, jprop, jvalue);
|
||||
break;
|
||||
default:
|
||||
ALOGV("sendPropertyUpdateToJava: Unknown property update format received in callback: %d!", prop->format);
|
||||
break;
|
||||
}
|
||||
if (jprop)
|
||||
env->DeleteLocalRef(jprop);
|
||||
if (jvalue)
|
||||
env->DeleteLocalRef(jvalue);
|
||||
}
|
||||
|
||||
static void sendEventToJava(JNIEnv *env, int event)
|
||||
{
|
||||
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_event, event);
|
||||
}
|
||||
|
||||
static void sendEndFileEventToJava(JNIEnv *env, mpv_event_end_file *event)
|
||||
{
|
||||
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_end_file_event, event->reason, event->error);
|
||||
}
|
||||
|
||||
static void sendLogMessageToJava(JNIEnv *env, mpv_event_log_message *msg)
|
||||
{
|
||||
// filter the most obvious cases of invalid utf-8, since Java would choke on it
|
||||
const auto invalid_utf8 = [] (unsigned char c) {
|
||||
return c == 0xc0 || c == 0xc1 || c >= 0xf5;
|
||||
};
|
||||
for (int i = 0; msg->text[i]; i++) {
|
||||
if (invalid_utf8(static_cast<unsigned char>(msg->text[i])))
|
||||
return;
|
||||
}
|
||||
|
||||
jstring jprefix = env->NewStringUTF(msg->prefix);
|
||||
jstring jtext = env->NewStringUTF(msg->text);
|
||||
|
||||
env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_logMessage_SiS,
|
||||
jprefix, (jint) msg->log_level, jtext);
|
||||
|
||||
if (jprefix)
|
||||
env->DeleteLocalRef(jprefix);
|
||||
if (jtext)
|
||||
env->DeleteLocalRef(jtext);
|
||||
}
|
||||
|
||||
void *event_thread(void *arg)
|
||||
{
|
||||
JNIEnv *env = NULL;
|
||||
acquire_jni_env(g_vm, &env);
|
||||
if (!env)
|
||||
die("failed to acquire java env");
|
||||
|
||||
while (1) {
|
||||
mpv_event *mp_event;
|
||||
mpv_event_property *mp_property = NULL;
|
||||
mpv_event_log_message *msg = NULL;
|
||||
mpv_event_end_file *mp_end_file = NULL;
|
||||
|
||||
mp_event = mpv_wait_event(g_mpv, -1.0);
|
||||
|
||||
if (g_event_thread_request_exit)
|
||||
break;
|
||||
|
||||
if (mp_event->event_id == MPV_EVENT_NONE)
|
||||
continue;
|
||||
|
||||
switch (mp_event->event_id) {
|
||||
case MPV_EVENT_LOG_MESSAGE:
|
||||
msg = (mpv_event_log_message*)mp_event->data;
|
||||
ALOGV("[%s:%s] %s", msg->prefix, msg->level, msg->text);
|
||||
sendLogMessageToJava(env, msg);
|
||||
break;
|
||||
case MPV_EVENT_PROPERTY_CHANGE:
|
||||
mp_property = (mpv_event_property*)mp_event->data;
|
||||
sendPropertyUpdateToJava(env, mp_property);
|
||||
break;
|
||||
case MPV_EVENT_END_FILE:
|
||||
mp_end_file = (mpv_event_end_file*)mp_event->data;
|
||||
sendEndFileEventToJava(env, mp_end_file);
|
||||
break;
|
||||
default:
|
||||
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
|
||||
sendEventToJava(env, mp_event->event_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
g_vm->DetachCurrentThread();
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
void *event_thread(void *arg);
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
|
||||
extern JavaVM *g_vm;
|
||||
extern mpv_handle *g_mpv;
|
||||
extern std::atomic<bool> g_event_thread_request_exit;
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
#define UTIL_EXTERN
|
||||
#include "jni_utils.h"
|
||||
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
bool acquire_jni_env(JavaVM *vm, JNIEnv **env)
|
||||
{
|
||||
int ret = vm->GetEnv((void**) env, JNI_VERSION_1_6);
|
||||
if (ret == JNI_EDETACHED)
|
||||
return vm->AttachCurrentThread(env, NULL) == 0;
|
||||
else
|
||||
return ret == JNI_OK;
|
||||
}
|
||||
|
||||
// Apparently it's considered slow to FindClass and GetMethodID every time we need them,
|
||||
// so let's have a nice cache here.
|
||||
|
||||
void init_methods_cache(JNIEnv *env)
|
||||
{
|
||||
static bool methods_initialized = false;
|
||||
if (methods_initialized)
|
||||
return;
|
||||
|
||||
#define FIND_CLASS(name) reinterpret_cast<jclass>(env->NewGlobalRef(env->FindClass(name)))
|
||||
java_Integer = FIND_CLASS("java/lang/Integer");
|
||||
java_Integer_init = env->GetMethodID(java_Integer, "<init>", "(I)V");
|
||||
java_Double = FIND_CLASS("java/lang/Double");
|
||||
java_Double_init = env->GetMethodID(java_Double, "<init>", "(D)V");
|
||||
java_Boolean = FIND_CLASS("java/lang/Boolean");
|
||||
java_Boolean_init = env->GetMethodID(java_Boolean, "<init>", "(Z)V");
|
||||
|
||||
android_graphics_Bitmap = FIND_CLASS("android/graphics/Bitmap");
|
||||
// createBitmap(int[], int, int, android.graphics.Bitmap$Config)
|
||||
android_graphics_Bitmap_createBitmap = env->GetStaticMethodID(android_graphics_Bitmap, "createBitmap", "([IIILandroid/graphics/Bitmap$Config;)Landroid/graphics/Bitmap;");
|
||||
android_graphics_Bitmap_Config = FIND_CLASS("android/graphics/Bitmap$Config");
|
||||
// static final android.graphics.Bitmap$Config ARGB_8888
|
||||
android_graphics_Bitmap_Config_ARGB_8888 = env->GetStaticFieldID(android_graphics_Bitmap_Config, "ARGB_8888", "Landroid/graphics/Bitmap$Config;");
|
||||
|
||||
mpv_MPVLib = FIND_CLASS("com/github/damontecres/wholphin/util/mpv/MPVLib");
|
||||
mpv_MPVLib_eventProperty_S = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;)V"); // eventProperty(String)
|
||||
mpv_MPVLib_eventProperty_Sb = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;Z)V"); // eventProperty(String, boolean)
|
||||
mpv_MPVLib_eventProperty_Sl = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;J)V"); // eventProperty(String, long)
|
||||
mpv_MPVLib_eventProperty_Sd = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;D)V"); // eventProperty(String, double)
|
||||
mpv_MPVLib_eventProperty_SS = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;Ljava/lang/String;)V"); // eventProperty(String, String)
|
||||
mpv_MPVLib_event = env->GetStaticMethodID(mpv_MPVLib, "event", "(I)V"); // event(int)
|
||||
mpv_MPVLib_end_file_event = env->GetStaticMethodID(mpv_MPVLib, "eventEndFile", "(II)V"); // eventEndFile(int, int)
|
||||
mpv_MPVLib_logMessage_SiS = env->GetStaticMethodID(mpv_MPVLib, "logMessage", "(Ljava/lang/String;ILjava/lang/String;)V"); // logMessage(String, int, String)
|
||||
#undef FIND_CLASS
|
||||
|
||||
methods_initialized = true;
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#define jni_func_name(name) Java_com_github_damontecres_wholphin_util_mpv_MPVLib_##name
|
||||
#define jni_func(return_type, name, ...) JNIEXPORT return_type JNICALL jni_func_name(name) (JNIEnv *env, jobject obj, ##__VA_ARGS__)
|
||||
|
||||
bool acquire_jni_env(JavaVM *vm, JNIEnv **env);
|
||||
void init_methods_cache(JNIEnv *env);
|
||||
|
||||
#ifndef UTIL_EXTERN
|
||||
#define UTIL_EXTERN extern
|
||||
#endif
|
||||
|
||||
UTIL_EXTERN jclass java_Integer, java_Double, java_Boolean;
|
||||
UTIL_EXTERN jmethodID java_Integer_init, java_Double_init, java_Boolean_init;
|
||||
|
||||
UTIL_EXTERN jclass android_graphics_Bitmap, android_graphics_Bitmap_Config;
|
||||
UTIL_EXTERN jmethodID android_graphics_Bitmap_createBitmap;
|
||||
UTIL_EXTERN jfieldID android_graphics_Bitmap_Config_ARGB_8888;
|
||||
|
||||
UTIL_EXTERN jclass mpv_MPVLib;
|
||||
UTIL_EXTERN jmethodID mpv_MPVLib_eventProperty_S,
|
||||
mpv_MPVLib_eventProperty_Sb,
|
||||
mpv_MPVLib_eventProperty_Sl,
|
||||
mpv_MPVLib_eventProperty_Sd,
|
||||
mpv_MPVLib_eventProperty_SS,
|
||||
mpv_MPVLib_event,
|
||||
mpv_MPVLib_end_file_event,
|
||||
mpv_MPVLib_logMessage_SiS;
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
#include "log.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
|
||||
void die(const char *msg)
|
||||
{
|
||||
ALOGE("%s", msg);
|
||||
exit(1);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <android/log.h>
|
||||
|
||||
#define DEBUG 0
|
||||
|
||||
#define LOG_TAG "mpv"
|
||||
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
#if DEBUG
|
||||
#define ALOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__)
|
||||
#else
|
||||
#define ALOGV(...) (void)0
|
||||
#endif
|
||||
|
||||
__attribute__((noreturn)) void die(const char *msg);
|
||||
|
||||
#define CHECK_MPV_INIT() do { \
|
||||
if (__builtin_expect(!g_mpv, 0)) \
|
||||
die("libmpv is not initialized"); \
|
||||
} while (0)
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
#include <locale.h>
|
||||
#include <atomic>
|
||||
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include <pthread.h>
|
||||
|
||||
extern "C" {
|
||||
#include <libavcodec/jni.h>
|
||||
}
|
||||
|
||||
#include "log.h"
|
||||
#include "jni_utils.h"
|
||||
#include "event.h"
|
||||
|
||||
#define ARRAYLEN(a) (sizeof(a)/sizeof(a[0]))
|
||||
|
||||
extern "C" {
|
||||
jni_func(void, create, jobject appctx);
|
||||
jni_func(void, init);
|
||||
jni_func(void, destroy);
|
||||
|
||||
jni_func(void, command, jobjectArray jarray);
|
||||
};
|
||||
|
||||
JavaVM *g_vm;
|
||||
mpv_handle *g_mpv;
|
||||
std::atomic<bool> g_event_thread_request_exit(false);
|
||||
|
||||
static pthread_t event_thread_id;
|
||||
|
||||
static void prepare_environment(JNIEnv *env, jobject appctx) {
|
||||
setlocale(LC_NUMERIC, "C");
|
||||
|
||||
if (!env->GetJavaVM(&g_vm) && g_vm)
|
||||
av_jni_set_java_vm(g_vm, NULL);
|
||||
|
||||
jobject global_appctx = env->NewGlobalRef(appctx);
|
||||
if (global_appctx)
|
||||
av_jni_set_android_app_ctx(global_appctx, NULL);
|
||||
|
||||
init_methods_cache(env);
|
||||
}
|
||||
|
||||
jni_func(void, create, jobject appctx) {
|
||||
prepare_environment(env, appctx);
|
||||
|
||||
if (g_mpv)
|
||||
die("mpv is already initialized");
|
||||
|
||||
g_mpv = mpv_create();
|
||||
if (!g_mpv)
|
||||
die("context init failed");
|
||||
|
||||
// use terminal log level but request verbose messages
|
||||
// this way --msg-level can be used to adjust later
|
||||
mpv_request_log_messages(g_mpv, "terminal-default");
|
||||
mpv_set_option_string(g_mpv, "msg-level", "all=v");
|
||||
}
|
||||
|
||||
jni_func(void, init) {
|
||||
if (!g_mpv)
|
||||
die("mpv is not created");
|
||||
|
||||
if (mpv_initialize(g_mpv) < 0)
|
||||
die("mpv init failed");
|
||||
|
||||
g_event_thread_request_exit = false;
|
||||
if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0)
|
||||
die("thread create failed");
|
||||
pthread_setname_np(event_thread_id, "event_thread");
|
||||
}
|
||||
|
||||
jni_func(void, destroy) {
|
||||
if (!g_mpv) {
|
||||
ALOGV("mpv destroy called but it's already destroyed");
|
||||
return;
|
||||
}
|
||||
|
||||
// poke event thread and wait for it to exit
|
||||
g_event_thread_request_exit = true;
|
||||
mpv_wakeup(g_mpv);
|
||||
pthread_join(event_thread_id, NULL);
|
||||
|
||||
mpv_terminate_destroy(g_mpv);
|
||||
g_mpv = NULL;
|
||||
}
|
||||
|
||||
jni_func(void, command, jobjectArray jarray) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
const char *arguments[128] = {0};
|
||||
int len = env->GetArrayLength(jarray);
|
||||
if (len >= ARRAYLEN(arguments))
|
||||
die("too many command arguments");
|
||||
|
||||
for (int i = 0; i < len; ++i)
|
||||
arguments[i] = env->GetStringUTFChars((jstring)env->GetObjectArrayElement(jarray, i), NULL);
|
||||
|
||||
mpv_command(g_mpv, arguments);
|
||||
|
||||
for (int i = 0; i < len; ++i)
|
||||
env->ReleaseStringUTFChars((jstring)env->GetObjectArrayElement(jarray, i), arguments[i]);
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
#include "globals.h"
|
||||
|
||||
extern "C" {
|
||||
jni_func(jint, setOptionString, jstring option, jstring value);
|
||||
|
||||
jni_func(jobject, getPropertyInt, jstring property);
|
||||
jni_func(void, setPropertyInt, jstring property, jint value);
|
||||
jni_func(jobject, getPropertyDouble, jstring property);
|
||||
jni_func(void, setPropertyDouble, jstring property, jdouble value);
|
||||
jni_func(jobject, getPropertyBoolean, jstring property);
|
||||
jni_func(void, setPropertyBoolean, jstring property, jboolean value);
|
||||
jni_func(jstring, getPropertyString, jstring jproperty);
|
||||
jni_func(void, setPropertyString, jstring jproperty, jstring jvalue);
|
||||
|
||||
jni_func(void, observeProperty, jstring property, jint format);
|
||||
}
|
||||
|
||||
jni_func(jint, setOptionString, jstring joption, jstring jvalue) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
const char *option = env->GetStringUTFChars(joption, NULL);
|
||||
const char *value = env->GetStringUTFChars(jvalue, NULL);
|
||||
|
||||
int result = mpv_set_option_string(g_mpv, option, value);
|
||||
|
||||
env->ReleaseStringUTFChars(joption, option);
|
||||
env->ReleaseStringUTFChars(jvalue, value);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int common_get_property(JNIEnv *env, jstring jproperty, mpv_format format, void *output)
|
||||
{
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
const char *prop = env->GetStringUTFChars(jproperty, NULL);
|
||||
int result = mpv_get_property(g_mpv, prop, format, output);
|
||||
if (result == MPV_ERROR_PROPERTY_UNAVAILABLE)
|
||||
ALOGV("mpv_get_property(%s) format %d was unavailable", prop, format);
|
||||
else if (result < 0)
|
||||
ALOGE("mpv_get_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(jproperty, prop);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int common_set_property(JNIEnv *env, jstring jproperty, mpv_format format, void *value)
|
||||
{
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
const char *prop = env->GetStringUTFChars(jproperty, NULL);
|
||||
int result = mpv_set_property(g_mpv, prop, format, value);
|
||||
if (result < 0)
|
||||
ALOGE("mpv_set_property(%s, %p) format %d returned error %s", prop, value, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(jproperty, prop);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
jni_func(jobject, getPropertyInt, jstring jproperty) {
|
||||
int64_t value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_INT64, &value) < 0)
|
||||
return NULL;
|
||||
return env->NewObject(java_Integer, java_Integer_init, (jint)value);
|
||||
}
|
||||
|
||||
jni_func(jobject, getPropertyDouble, jstring jproperty) {
|
||||
double value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_DOUBLE, &value) < 0)
|
||||
return NULL;
|
||||
return env->NewObject(java_Double, java_Double_init, (jdouble)value);
|
||||
}
|
||||
|
||||
jni_func(jobject, getPropertyBoolean, jstring jproperty) {
|
||||
int value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_FLAG, &value) < 0)
|
||||
return NULL;
|
||||
return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value);
|
||||
}
|
||||
|
||||
jni_func(jstring, getPropertyString, jstring jproperty) {
|
||||
char *value;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_STRING, &value) < 0)
|
||||
return NULL;
|
||||
jstring jvalue = env->NewStringUTF(value);
|
||||
mpv_free(value);
|
||||
return jvalue;
|
||||
}
|
||||
|
||||
jni_func(void, setPropertyInt, jstring jproperty, jint jvalue) {
|
||||
int64_t value = static_cast<int64_t>(jvalue);
|
||||
common_set_property(env, jproperty, MPV_FORMAT_INT64, &value);
|
||||
}
|
||||
|
||||
jni_func(void, setPropertyDouble, jstring jproperty, jdouble jvalue) {
|
||||
double value = static_cast<double>(jvalue);
|
||||
common_set_property(env, jproperty, MPV_FORMAT_DOUBLE, &value);
|
||||
}
|
||||
|
||||
jni_func(void, setPropertyBoolean, jstring jproperty, jboolean jvalue) {
|
||||
int value = jvalue == JNI_TRUE ? 1 : 0;
|
||||
common_set_property(env, jproperty, MPV_FORMAT_FLAG, &value);
|
||||
}
|
||||
|
||||
jni_func(void, setPropertyString, jstring jproperty, jstring jvalue) {
|
||||
const char *value = env->GetStringUTFChars(jvalue, NULL);
|
||||
common_set_property(env, jproperty, MPV_FORMAT_STRING, &value);
|
||||
env->ReleaseStringUTFChars(jvalue, value);
|
||||
}
|
||||
|
||||
jni_func(void, observeProperty, jstring property, jint format) {
|
||||
CHECK_MPV_INIT();
|
||||
const char *prop = env->GetStringUTFChars(property, NULL);
|
||||
int result = mpv_observe_property(g_mpv, 0, prop, (mpv_format)format);
|
||||
if (result < 0)
|
||||
ALOGE("mpv_observe_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(property, prop);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
#include <jni.h>
|
||||
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
#include "globals.h"
|
||||
|
||||
extern "C" {
|
||||
jni_func(void, attachSurface, jobject surface_);
|
||||
jni_func(void, detachSurface);
|
||||
};
|
||||
|
||||
static jobject surface;
|
||||
|
||||
jni_func(void, attachSurface, jobject surface_) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
surface = env->NewGlobalRef(surface_);
|
||||
if (!surface)
|
||||
die("invalid surface provided");
|
||||
int64_t wid = reinterpret_cast<intptr_t>(surface);
|
||||
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0)
|
||||
ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
|
||||
}
|
||||
|
||||
jni_func(void, detachSurface) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
int64_t wid = 0;
|
||||
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0)
|
||||
ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
|
||||
|
||||
env->DeleteGlobalRef(surface);
|
||||
surface = NULL;
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
#include <stdlib.h>
|
||||
#include <string>
|
||||
|
||||
#include <jni.h>
|
||||
#include <android/bitmap.h>
|
||||
#include <mpv/client.h>
|
||||
|
||||
extern "C" {
|
||||
#include <libswscale/swscale.h>
|
||||
};
|
||||
|
||||
#include "jni_utils.h"
|
||||
#include "globals.h"
|
||||
#include "log.h"
|
||||
|
||||
extern "C" {
|
||||
jni_func(jobject, grabThumbnail, jint dimension);
|
||||
};
|
||||
|
||||
static inline mpv_node make_node_str(const char *s)
|
||||
{
|
||||
mpv_node r{};
|
||||
r.format = MPV_FORMAT_STRING;
|
||||
r.u.string = const_cast<char*>(s);
|
||||
return r;
|
||||
}
|
||||
|
||||
jni_func(jobject, grabThumbnail, jint dimension) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
mpv_node result{};
|
||||
{
|
||||
mpv_node c{}, c_args[2];
|
||||
mpv_node_list c_array{};
|
||||
c_args[0] = make_node_str("screenshot-raw");
|
||||
c_args[1] = make_node_str("video");
|
||||
c_array.num = 2;
|
||||
c_array.values = c_args;
|
||||
c.format = MPV_FORMAT_NODE_ARRAY;
|
||||
c.u.list = &c_array;
|
||||
if (mpv_command_node(g_mpv, &c, &result) < 0) {
|
||||
ALOGE("screenshot-raw command failed");
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
// extract relevant property data from the node map mpv returns
|
||||
int w = 0, h = 0, stride = 0;
|
||||
bool format_ok = false;
|
||||
struct mpv_byte_array *data = NULL;
|
||||
do {
|
||||
if (result.format != MPV_FORMAT_NODE_MAP)
|
||||
break;
|
||||
for (int i = 0; i < result.u.list->num; i++) {
|
||||
std::string key(result.u.list->keys[i]);
|
||||
const mpv_node *val = &result.u.list->values[i];
|
||||
if (key == "w" || key == "h" || key == "stride") {
|
||||
if (val->format != MPV_FORMAT_INT64)
|
||||
break;
|
||||
if (key == "w")
|
||||
w = val->u.int64;
|
||||
else if (key == "h")
|
||||
h = val->u.int64;
|
||||
else
|
||||
stride = val->u.int64;
|
||||
} else if (key == "format") {
|
||||
if (val->format != MPV_FORMAT_STRING)
|
||||
break;
|
||||
format_ok = !strcmp(val->u.string, "bgr0");
|
||||
} else if (key == "data") {
|
||||
if (val->format != MPV_FORMAT_BYTE_ARRAY)
|
||||
break;
|
||||
data = val->u.ba;
|
||||
}
|
||||
}
|
||||
} while (0);
|
||||
if (!w || !h || !stride || !format_ok || !data) {
|
||||
ALOGE("extracting data failed");
|
||||
mpv_free_node_contents(&result);
|
||||
return NULL;
|
||||
}
|
||||
ALOGV("screenshot w:%d h:%d stride:%d", w, h, stride);
|
||||
|
||||
// crop to square
|
||||
int crop_left = 0, crop_top = 0;
|
||||
int new_w = w, new_h = h;
|
||||
if (w > h) {
|
||||
crop_left = (w - h) / 2;
|
||||
new_w = h;
|
||||
} else {
|
||||
crop_top = (h - w) / 2;
|
||||
new_h = w;
|
||||
}
|
||||
ALOGV("cropped w:%u h:%u", new_w, new_h);
|
||||
|
||||
uint8_t *new_data = reinterpret_cast<uint8_t*>(data->data);
|
||||
new_data += crop_left * sizeof(uint32_t); // move begin rightwards
|
||||
new_data += stride * crop_top; // move begin downwards
|
||||
|
||||
// convert & scale to appropriate size
|
||||
struct SwsContext *ctx = sws_getContext(
|
||||
new_w, new_h, AV_PIX_FMT_BGR0,
|
||||
dimension, dimension, AV_PIX_FMT_RGB32,
|
||||
SWS_BICUBIC, NULL, NULL, NULL);
|
||||
if (!ctx) {
|
||||
mpv_free_node_contents(&result);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
jintArray arr = env->NewIntArray(dimension * dimension);
|
||||
jint *scaled = env->GetIntArrayElements(arr, NULL);
|
||||
|
||||
uint8_t *src_p[4] = { new_data }, *dst_p[4] = { (uint8_t*) scaled };
|
||||
int src_stride[4] = { stride },
|
||||
dst_stride[4] = { (int) sizeof(jint) * dimension };
|
||||
sws_scale(ctx, src_p, src_stride, 0, new_h, dst_p, dst_stride);
|
||||
sws_freeContext(ctx);
|
||||
|
||||
mpv_free_node_contents(&result); // frees data->data
|
||||
|
||||
// create android.graphics.Bitmap
|
||||
env->ReleaseIntArrayElements(arr, scaled, 0);
|
||||
|
||||
jobject bitmap_config =
|
||||
env->GetStaticObjectField(android_graphics_Bitmap_Config, android_graphics_Bitmap_Config_ARGB_8888);
|
||||
jobject bitmap =
|
||||
env->CallStaticObjectMethod(android_graphics_Bitmap, android_graphics_Bitmap_createBitmap,
|
||||
arr, dimension, dimension, bitmap_config);
|
||||
env->DeleteLocalRef(arr);
|
||||
env->DeleteLocalRef(bitmap_config);
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
|
|
@ -66,11 +66,11 @@
|
|||
<string name="more">المزيد</string>
|
||||
<plurals name="movies">
|
||||
<item quantity="zero">الأفلام</item>
|
||||
<item quantity="one"></item>
|
||||
<item quantity="two"></item>
|
||||
<item quantity="few"></item>
|
||||
<item quantity="many"></item>
|
||||
<item quantity="other"></item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="two"/>
|
||||
<item quantity="few"/>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<string name="name">الاسم</string>
|
||||
<string name="next_up">التالي</string>
|
||||
|
|
@ -85,11 +85,11 @@
|
|||
<string name="path">المسار</string>
|
||||
<plurals name="people">
|
||||
<item quantity="zero">الأشخاص</item>
|
||||
<item quantity="one"></item>
|
||||
<item quantity="two"></item>
|
||||
<item quantity="few"></item>
|
||||
<item quantity="many"></item>
|
||||
<item quantity="other"></item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="two"/>
|
||||
<item quantity="few"/>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<string name="play_count">عدد مرات التشغيل</string>
|
||||
<string name="play_from_here">تشغيل من هنا</string>
|
||||
|
|
@ -128,4 +128,26 @@
|
|||
<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>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
<string name="no_scheduled_recordings">Keine geplanten Aufnahmen</string>
|
||||
<string name="no_update_available">Kein Update verfügbar</string>
|
||||
<string name="path">Pfad</string>
|
||||
<string name="people">Personen</string>
|
||||
<string name="people">Darsteller</string>
|
||||
<string name="recently_added_in">Zuletzt hinzugefügt in %1$s</string>
|
||||
<string name="recently_added">Zuletzt hinzugefügt</string>
|
||||
<string name="recently_recorded">Zuletzt aufgenommen</string>
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
<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="compose_error_message_title">Ein Fehler ist aufgetreten! Drücke die Taste um Logs an deinen Server zu senden.</string>
|
||||
<string name="directed_by">Regie von %1$s</string>
|
||||
<string name="disabled">Deaktiviert</string>
|
||||
<string name="discovered_servers">Gefundene Server</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">Wiedergeben</string>
|
||||
<string name="play">Abspielen</string>
|
||||
<string name="playback_speed">Wiedergabegeschwindigkeit</string>
|
||||
<string name="playback">Wiedergabe</string>
|
||||
<string name="preview">Vorschau</string>
|
||||
|
|
@ -132,21 +132,21 @@
|
|||
<string name="search_and_download"><![CDATA[Suchen & Herunterladen]]></string>
|
||||
<string name="show_debug_info">Debug Infos anzeigen</string>
|
||||
<string name="download_and_update"><![CDATA[Herunterladen & Aktualisieren]]></string>
|
||||
<string name="shuffle">Zufall</string>
|
||||
<string name="shuffle">Zufällig</string>
|
||||
<string name="skip">Überspringen</string>
|
||||
<string name="sort_by_date_added">Datum hinzugefügt</string>
|
||||
<string name="sort_by_date_episode_added">Datum Episode hinzugefügt</string>
|
||||
<string name="sort_by_date_played">Datum abgespielt</string>
|
||||
<string name="sort_by_date_added">Hinzugefügt am</string>
|
||||
<string name="sort_by_date_episode_added">Episode hinzugefügt am</string>
|
||||
<string name="sort_by_date_played">Wiedergegeben am</string>
|
||||
<string name="sort_by_date_released">Erscheinungsdatum</string>
|
||||
<string name="sort_by_random">Zufällig</string>
|
||||
<string name="submit">Absenden</string>
|
||||
<string name="subtitle_download_too_long">Der Download benötigt eine längere Zeit, evtl. musst du die Wiedergabe erneut starten</string>
|
||||
<string name="submit">Übermitteln</string>
|
||||
<string name="subtitle_download_too_long">Herunterladen dauert sehr lange, vielleicht hilft ein Neustart der Wiedergabe</string>
|
||||
<string name="subtitle">Untertitel</string>
|
||||
<string name="suggestions">Vorschläge</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>
|
||||
<string name="top_unwatched">Am besten bewertet ungesehen</string>
|
||||
<string name="tv_guide">Guide</string>
|
||||
<string name="play_with_transcoding">Mit Transcoding wiedergeben</string>
|
||||
<string name="show_clock">Uhr anzeigen</string>
|
||||
<string name="create_playlist">Neue Playlist erstellen</string>
|
||||
<string name="add_to_playlist">Zur Playlist hinzufügen</string>
|
||||
|
|
@ -183,15 +183,15 @@
|
|||
<string name="live_tv">Live TV</string>
|
||||
<string name="outro">Outro</string>
|
||||
<string name="play_count">Wiedergabe Anzahl</string>
|
||||
<string name="playback_debug_info">Zeige Wiedergabe Debug infos</string>
|
||||
<string name="playback_debug_info">Wiedergabe Debug Infos anzeigen</string>
|
||||
<string name="playlist">Wiedergabeliste</string>
|
||||
<string name="playlists">Wiedergabelisten</string>
|
||||
<string name="show_next_up_when">Zeige als nächstes</string>
|
||||
<string name="show">Zeigen</string>
|
||||
<string name="sort_by_name">Name</string>
|
||||
<string name="studios">Studios</string>
|
||||
<string name="tv_dvr_schedule">DVR Zeitplan</string>
|
||||
<string name="video_scale">Videogröße</string>
|
||||
<string name="tv_dvr_schedule">Aufnahmeplan</string>
|
||||
<string name="video_scale">Video Skalierung</string>
|
||||
<string name="video">Video</string>
|
||||
<string name="videos">Videos</string>
|
||||
<string name="aired_episode_order">Nach Sendedatum sortiert</string>
|
||||
|
|
@ -223,12 +223,12 @@
|
|||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<plurals name="shorts">
|
||||
<item quantity="one">Shorts</item>
|
||||
<item quantity="other">Kurzvideos</item>
|
||||
<item quantity="one">Short</item>
|
||||
<item quantity="other">Shorts</item>
|
||||
</plurals>
|
||||
<string name="combine_continue_next_summary">Trifft nur auf Serien zu</string>
|
||||
<string name="direct_play_ass">Libass für ASS Untertitel verwenden</string>
|
||||
<string name="direct_play_pgs">Direktwiedergabe PGS Untertitel</string>
|
||||
<string name="direct_play_ass">Direktes abspielen von ASS Untertitel</string>
|
||||
<string name="direct_play_pgs">Direktes abspielen von PGS Untertitel</string>
|
||||
<string name="downmix_stereo">Immer zu Stereo heruntermischen</string>
|
||||
<string name="ffmpeg_extension_pref">FFmpeg decoder Modul verwenden</string>
|
||||
<string name="global_content_scale">Standard Skalierung</string>
|
||||
|
|
@ -250,10 +250,10 @@
|
|||
<string name="update_url">Aktualisierungs-URL</string>
|
||||
<string name="font_size">Schriftgröße</string>
|
||||
<string name="font_color">Schriftfarbe</string>
|
||||
<string name="font_opacity">Schriftart Deckkraft</string>
|
||||
<string name="font_opacity">Deckkraft der Schrift</string>
|
||||
<string name="edge_style">Stil der Umrandung</string>
|
||||
<string name="edge_color">Farbe der Umrandung</string>
|
||||
<string name="background_opacity">Hintergrund Deckktraft</string>
|
||||
<string name="background_opacity">Deckkraft des Hintergrundes</string>
|
||||
<string name="background_style">Hintergrund Stil</string>
|
||||
<string name="subtitle_style">Untertitel Stil</string>
|
||||
<string name="background_color">Hintergrundfarbe</string>
|
||||
|
|
@ -406,7 +406,7 @@
|
|||
<string name="resolution_switching">Auflösungswechsel</string>
|
||||
<string name="guest_stars">Gaststars</string>
|
||||
<string name="slideshow_at_beginning">Ab Anfang</string>
|
||||
<string name="no_more_images">keine Fotos</string>
|
||||
<string name="no_more_images">Keine Fotos</string>
|
||||
<string name="rotate_left">Links Rotieren</string>
|
||||
<string name="rotate_right">Rechts Rotieren</string>
|
||||
<string name="red">Rot</string>
|
||||
|
|
@ -439,21 +439,21 @@
|
|||
</plurals>
|
||||
<string name="interlaced">Interlaced</string>
|
||||
<string name="nal">NAL</string>
|
||||
<string name="quick_connect_summary">Gewähre anderen Geräten Zugriff auf deinen Account</string>
|
||||
<string name="quick_connect_code">Eingabe Quick Connect Code</string>
|
||||
<string name="no_limit">Keine Beschränkung</string>
|
||||
<string name="quick_connect_summary">Gewähre anderen Geräten Zugang zu deinem Account</string>
|
||||
<string name="quick_connect_code">Quick Connect-Code eingeben</string>
|
||||
<string name="no_limit">Keine Begrenzung</string>
|
||||
<string name="add_row">Reihe hinzufügen</string>
|
||||
<string name="genres_in">Genres in %1$s</string>
|
||||
<string name="recently_released_in">kürzlich veröffentlicht in %1$s</string>
|
||||
<string name="height">Höhe</string>
|
||||
<string name="apply_all_rows">Auf alle Reihen anwenden</string>
|
||||
<string name="add_row_for">Reihe hinzufügen für %1$s</string>
|
||||
<string name="overwrite_server_settings">Überschreibe Einstellungen auf dem Server?</string>
|
||||
<string name="overwrite_local_settings">Überschreibe lokale Einstellungen?</string>
|
||||
<string name="add_row_for">Füge Reihe für %1$s hinzu</string>
|
||||
<string name="overwrite_server_settings">Einstellungen auf dem Server übverschreiben?</string>
|
||||
<string name="overwrite_local_settings">Lokale Einstellungen überschreiben?</string>
|
||||
<string name="suggestions_for">Vorschläge für %1$s</string>
|
||||
<string name="send_media_info_log_to_server">Sende Media Info Log zum Server</string>
|
||||
<string name="customize_home">Anpassung Homepage</string>
|
||||
<string name="for_episodes">Für Episoden</string>
|
||||
<string name="for_episodes">Für Folgen</string>
|
||||
<string name="display_presets_description">Vorlagen um schnell alle Reihen zu gestalten</string>
|
||||
<string name="customize_home_summary">Wähle Reihen und Bilder auf der Homepage</string>
|
||||
<string name="start_after">Starte anschließend</string>
|
||||
|
|
@ -542,13 +542,40 @@
|
|||
<string name="arranger">Arrangeur</string>
|
||||
<string name="engineer">Ingenieur</string>
|
||||
<string name="bold_blue">Dunkelblau</string>
|
||||
<string name="image_subtitle_opacity">Untertitel-Transparenz</string>
|
||||
<string name="image_subtitle_opacity">Transparenz des Bilduntertitels</string>
|
||||
<string name="image_type_thumb">Vorschaubild</string>
|
||||
<string name="use_series">Serien Vorschaubilder verwenden</string>
|
||||
<string name="enter_url_api_key"><![CDATA[URL & API Key eingeben]]></string>
|
||||
<string name="search_and_download_subtitles"><![CDATA[Untertitel suchen & herunterladen]]></string>
|
||||
<string name="creator">Ersteller</string>
|
||||
<string name="background_style_wrap">Gestreckt</string>
|
||||
<string name="background_style_boxed">Zentriert</string>
|
||||
<string name="background_style_wrap">Textmarker</string>
|
||||
<string name="background_style_boxed">Kasten</string>
|
||||
<string name="mixer">Mischer</string>
|
||||
<string name="select_all">Alle auswählen</string>
|
||||
<string name="albums">Alben</string>
|
||||
<string name="artists">Interpreten</string>
|
||||
<string name="songs">Lieder</string>
|
||||
<string name="go_to_artist">Zu Interpret wechseln</string>
|
||||
<string name="now_playing">Läuft gerade</string>
|
||||
<string name="instant_mix">Sofortmix</string>
|
||||
<string name="go_to_album">Zu Album wechseln</string>
|
||||
<string name="add_to_queue">Zur Warteschlange hinzufügen</string>
|
||||
<string name="album_artist">Album Interpret</string>
|
||||
<string name="album">Album</string>
|
||||
<string name="popular_songs">Beliebte Lieder</string>
|
||||
<string name="play_next">Als nächstes abspielen</string>
|
||||
<string name="music_videos">Musikvideos</string>
|
||||
<string name="lyrics">Liedtexte</string>
|
||||
<string name="hide_lyrics">Liedtexte ausblenden</string>
|
||||
<string name="show_lyrics">Liedtexte einblenden</string>
|
||||
<string name="song_has_lyrics">Lied hat Liedtext</string>
|
||||
<string name="remove_from_queue">Aus Warteschlange entfernen</string>
|
||||
<string name="show_album_cover">Album Cover anzeigen</string>
|
||||
<string name="show_visualizer">Visualizer anzeigen</string>
|
||||
<string name="show_backdrop">Hintergrundbild anzeigen</string>
|
||||
<string name="play_as_type">Abspielen als?</string>
|
||||
<string name="visualizer_rationale">Die Visualisierung der aktuellen Wiedergabe erfordert die Berechtigung zur Audioaufzeichnung.</string>
|
||||
<string name="continue_string">Fortfahren</string>
|
||||
<string name="separate_types">Verschiedene Arten</string>
|
||||
<string name="prefer_logos">Logos anstatt Titelnamen anzeigen</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -406,7 +406,7 @@
|
|||
<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">Direct play 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>
|
||||
|
|
@ -569,4 +569,31 @@
|
|||
<string name="delete_item">¿Estás seguro de que quieres eliminar este elemento?</string>
|
||||
<string name="show_media_management">Mostrar opciones de gestión de medios</string>
|
||||
<string name="search_for">Buscar %s</string>
|
||||
<string name="select_all">Seleccionar todo</string>
|
||||
<string name="show_album_cover">Mostrar portada del álbum</string>
|
||||
<string name="show_visualizer">Mostrar visualizador</string>
|
||||
<string name="show_backdrop">Mostrar imagen de fondo</string>
|
||||
<string name="play_as_type">Reproducir en</string>
|
||||
<string name="visualizer_rationale">Para visualizar el audio en reproducción, se requiere permiso para grabar audio.</string>
|
||||
<string name="continue_string">Continuar</string>
|
||||
<string name="albums">Álbumes</string>
|
||||
<string name="artists">Artistas</string>
|
||||
<string name="songs">Canciones</string>
|
||||
<string name="go_to_artist">Ir al artista</string>
|
||||
<string name="now_playing">Reproduciendo</string>
|
||||
<string name="instant_mix">Mezcla instantánea</string>
|
||||
<string name="go_to_album">Ir al álbum</string>
|
||||
<string name="add_to_queue">Añadir a la cola</string>
|
||||
<string name="album_artist">Artista del álbum</string>
|
||||
<string name="album">Álbum</string>
|
||||
<string name="popular_songs">Más escuchadas</string>
|
||||
<string name="play_next">Reproducir a continuación</string>
|
||||
<string name="music_videos">Videoclips</string>
|
||||
<string name="lyrics">Letras</string>
|
||||
<string name="hide_lyrics">Ocultar letras</string>
|
||||
<string name="show_lyrics">Mostrar letras</string>
|
||||
<string name="song_has_lyrics">Canción con letra</string>
|
||||
<string name="remove_from_queue">Eliminar de la cola</string>
|
||||
<string name="separate_types">Separar por tipos</string>
|
||||
<string name="prefer_logos">Preferir logotipos para los títulos</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -464,4 +464,92 @@
|
|||
<string name="display_presets_description">Rakenduses kaasasolevad eelseadistused kõikide ridade kiireks muutmiseks</string>
|
||||
<string name="customize_home_summary">Vali kodulehe/avalehe read ja pildid</string>
|
||||
<string name="favorite_items">Märgi %s lemmikuks</string>
|
||||
<plurals name="minutes">
|
||||
<item quantity="one">%s minut</item>
|
||||
<item quantity="other">%s minutit</item>
|
||||
</plurals>
|
||||
<string name="purple">Purpurpunane</string>
|
||||
<string name="orange">Oranž</string>
|
||||
<string name="bold_blue">Tugev sinine</string>
|
||||
<string name="black">Must</string>
|
||||
<string name="skip_ignore">Eira</string>
|
||||
<string name="skip_automatically">Jäta automaatselt vahele</string>
|
||||
<string name="skip_ask">Vahelejätmiseks küsi</string>
|
||||
<string name="ffmpeg_fallback">Kui ühtegi kaasnevat dekoodrit pole, siis kasuta vaid FFmpeg-i</string>
|
||||
<string name="ffmpeg_prefer">Eelista kaasnevatele dekoodritele FFmpeg-i</string>
|
||||
<string name="ffmpeg_never">Ära iialgi kasuta dekoderimiseks FFmpeg-i</string>
|
||||
<string name="next_up_playback_end">Taasesituse lõpus</string>
|
||||
<string name="white">Valge</string>
|
||||
<string name="light_gray">Helehall</string>
|
||||
<string name="dark_gray">Tumehall</string>
|
||||
<string name="yellow">Kollane</string>
|
||||
<string name="cyan">Rohekassinine</string>
|
||||
<string name="magenta">Fuksiapunane</string>
|
||||
<string name="subtitle_edge_outline">Äärisjoon</string>
|
||||
<string name="subtitle_edge_shadow">Vari</string>
|
||||
<string name="background_style_wrap">Reamurdmine</string>
|
||||
<string name="prefer_mpv">Eelista MPV-d</string>
|
||||
<string name="player_backend_options_subtitles_prefer_mpv">HDR-i taasesituseks kasuta ExoPlayerit</string>
|
||||
<string name="aspect_ratios_poster">Plakat (2:3)</string>
|
||||
<string name="aspect_ratios_16_9">16:9</string>
|
||||
<string name="aspect_ratios_4_3">4:3</string>
|
||||
<string name="aspect_ratios_square">Ruut (1:1)</string>
|
||||
<string name="image_type_thumb">Sõrmejälg</string>
|
||||
<string name="image_type_primary">Esmane</string>
|
||||
<string name="backdrop_style_dynamic">Pilt dünaamiliste värvidega</string>
|
||||
<string name="backdrop_style_image">Ainult pilt</string>
|
||||
<string name="enter_url_api_key"><![CDATA[Sisesta võrguaadress ja API võti]]></string>
|
||||
<string name="api_key">API võti</string>
|
||||
<string name="seerr_login">Logi sisse Seerri serverisse</string>
|
||||
<string name="seerr_jellyfin_user">Jellyfini kasutaja</string>
|
||||
<string name="seerr_local_user">Kohalik kasutaja</string>
|
||||
<string name="search_and_download_subtitles"><![CDATA[Subtiitrite otsing ja allalaadimine]]></string>
|
||||
<string name="no_subtitles_found">Kaugseadmest ei leidnunud subtiitreid</string>
|
||||
<string name="in_app_screensaver">Kasuta rakendusesisest ekraanisäästjat</string>
|
||||
<string name="delete_item">Kas sa oled kindel, et soovid selle objekti kustutada?</string>
|
||||
<string name="show_media_management">Näida meediumi haldamise valikuid</string>
|
||||
<string name="actor">Näitleja</string>
|
||||
<string name="composer">Helilooja</string>
|
||||
<string name="writer">Kirjanik</string>
|
||||
<string name="guest_star">Külalisesineja</string>
|
||||
<string name="producer">Tootja</string>
|
||||
<string name="conductor">Dirigent</string>
|
||||
<string name="lyricist">Laulusõnade autor</string>
|
||||
<string name="arranger">Seade autor</string>
|
||||
<string name="engineer">Heliinsener</string>
|
||||
<string name="mixer">Helimiksija</string>
|
||||
<string name="creator">Looja</string>
|
||||
<string name="artist">Kunstnik</string>
|
||||
<string name="search_for">Otsi: %s</string>
|
||||
<string name="select_all">Vali kõik</string>
|
||||
<string name="background_style_boxed">Ruudustatud</string>
|
||||
<string name="screensaver_settings">Ekraanisäästja seadistused</string>
|
||||
<string name="start_screensaver">Käivita ekraanisäästja</string>
|
||||
<string name="duration">Kestus</string>
|
||||
<string name="photos">Fotod</string>
|
||||
<string name="include_types">Kaasa tüübid</string>
|
||||
<string name="content_scale_fit">Sobita</string>
|
||||
<string name="content_scale_crop">Kadreeri</string>
|
||||
<string name="content_scale_fill">Täida</string>
|
||||
<string name="content_scale_fill_width">Täida laiuses</string>
|
||||
<string name="content_scale_fill_height">Täida kõrguses</string>
|
||||
<string name="display_preset_default">Wholphini vaikeseadistus</string>
|
||||
<string name="display_preset_compact">Wholphini kompaktne vaade</string>
|
||||
<string name="display_preset_series_thumb">Sarja pisipildid</string>
|
||||
<string name="display_preset_episode_thumbnails">Osa pisipildid</string>
|
||||
<string name="volume_lowest">Vaikseim</string>
|
||||
<string name="volume_low">Vaikne</string>
|
||||
<string name="volume_medium">Keskmine</string>
|
||||
<string name="volume_high">Vali</string>
|
||||
<string name="volume_full">Täisvaljus</string>
|
||||
<string name="start_after">Käivita peale</string>
|
||||
<string name="animate">Animeeri</string>
|
||||
<string name="max_age_rating">Ülemine vanusepiirang</string>
|
||||
<string name="no_max">Puudub</string>
|
||||
<string name="for_all_ages">Sobilik kõikidele vanustele</string>
|
||||
<string name="up_to_age">Kuni vanuseni %s</string>
|
||||
<string name="screensaver">Ekraanisäästja</string>
|
||||
<string name="next_up_outro">Lõputiitrite ja lõpuklipi ajal</string>
|
||||
<string name="series_continueing">Jätkuv sari</string>
|
||||
<string name="include_types_summary">Mis tüüpi objekte peaks piltide kontekstis näitama</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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 maximal d\'éléments par ligne sur la page d\'accueil</string>
|
||||
<string name="max_homepage_items">Eléments sur les lignes de 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>
|
||||
|
|
@ -566,4 +566,8 @@
|
|||
<string name="mixer">Mélangeur</string>
|
||||
<string name="creator">Créateur</string>
|
||||
<string name="artist">Artiste</string>
|
||||
<string name="delete_item">Êtes-vous sûr de vouloir supprimer cet élément ?</string>
|
||||
<string name="show_media_management">Afficher les options de gestion des médias</string>
|
||||
<string name="search_for">Recherche %s</string>
|
||||
<string name="select_all">Tout sélectionner</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -522,4 +522,16 @@
|
|||
<string name="engineer">Teknisi</string>
|
||||
<string name="mixer">Mixer</string>
|
||||
<string name="creator">Kreator</string>
|
||||
<string name="select_all">Pilih semua</string>
|
||||
<string name="search_for">Cari %s</string>
|
||||
<string name="show_media_management">Tampilkan opsi kelola media</string>
|
||||
<string name="delete_item">Apakah kamu yakin ingin menghapus item ini?</string>
|
||||
<string name="subtitle_edge_shadow">Bayangan</string>
|
||||
<string name="background_style_wrap">Pembungkus</string>
|
||||
<string name="background_style_boxed">Kotak</string>
|
||||
<string name="content_scale_fit">Sesuaikan</string>
|
||||
<string name="content_scale_crop">Pangkas</string>
|
||||
<string name="content_scale_fill">Layar penuh</string>
|
||||
<string name="content_scale_fill_width">Lebar penuh</string>
|
||||
<string name="content_scale_fill_height">Tinggi penuh</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -569,4 +569,5 @@
|
|||
<string name="search_for">Cerca %s</string>
|
||||
<string name="delete_item">Sei sicuro di voler cancellare questo elemento?</string>
|
||||
<string name="show_media_management">Mostra opzioni di gestione dei media</string>
|
||||
<string name="select_all">Seleziona tutti</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@
|
|||
<string name="commercial">מסחרי</string>
|
||||
<string name="community_rating">דירוג קהילתי</string>
|
||||
<string name="compose_error_message_title">אירעה שגיאה! לחץ על הכפתור על מנת לשלוח יומני אירועים לשרת שלך.</string>
|
||||
<string name="confirm">לאשר</string>
|
||||
<string name="confirm">אשר</string>
|
||||
<string name="continue_watching">המשך צפייה</string>
|
||||
<string name="critic_rating">דירוג מבקרים</string>
|
||||
<string name="decimal_seconds">%.1f שניות</string>
|
||||
<string name="decimal_seconds">%.2f שניות</string>
|
||||
<string name="default_track">ברירת מחדל</string>
|
||||
<string name="delete">מחק</string>
|
||||
<string name="died">מת</string>
|
||||
|
|
@ -30,4 +30,115 @@
|
|||
<string name="disabled">מושבת</string>
|
||||
<string name="born">נולד</string>
|
||||
<string name="choose_stream">בחר %1$s</string>
|
||||
<string name="select_server">בחר שרת</string>
|
||||
<string name="discovered_servers">שרתים שנמצאו</string>
|
||||
<string name="searching">מחפש…</string>
|
||||
<string name="enter_server_address">הזן כתובת שרת</string>
|
||||
<string name="select_user">בחר משתמש</string>
|
||||
<string name="switch_servers">החלף שרתים</string>
|
||||
<string name="search">חיפוש</string>
|
||||
<string name="home">דף הבית</string>
|
||||
<string name="favorites">מועדפים</string>
|
||||
<plurals name="movies">
|
||||
<item quantity="one">סרטים</item>
|
||||
<item quantity="two"></item>
|
||||
<item quantity="other"></item>
|
||||
</plurals>
|
||||
<plurals name="tv_shows">
|
||||
<item quantity="one">סדרות</item>
|
||||
<item quantity="two"></item>
|
||||
<item quantity="other"></item>
|
||||
</plurals>
|
||||
<string name="tv_seasons">עונות</string>
|
||||
<string name="tv_season">עונה</string>
|
||||
<string name="trailer">טריילר</string>
|
||||
<plurals name="trailers">
|
||||
<item quantity="one">טריילרים</item>
|
||||
<item quantity="two"></item>
|
||||
<item quantity="other"></item>
|
||||
</plurals>
|
||||
<string name="subtitle">כתובית</string>
|
||||
<string name="subtitles">כתוביות</string>
|
||||
<string name="ui_interface">ממשק</string>
|
||||
<string name="sign_in_auto">התחבר אוטומטית</string>
|
||||
<string name="remember_selected_tab">זכור כרטיסיות שנבחרו</string>
|
||||
<string name="enabled">מופעל</string>
|
||||
<string name="volume_lowest">נמוך ביותר</string>
|
||||
<string name="volume_low">נמוך</string>
|
||||
<string name="volume_medium">בינוני</string>
|
||||
<string name="volume_high">גבוה</string>
|
||||
<string name="volume_full">מלא</string>
|
||||
<string name="purple">סגול</string>
|
||||
<string name="orange">כתום</string>
|
||||
<string name="black">שחור</string>
|
||||
<string name="white">לבן</string>
|
||||
<string name="login">התחבר</string>
|
||||
<string name="username">שם משתמש</string>
|
||||
<string name="password">סיסמה</string>
|
||||
<string name="downloading">מוריד…</string>
|
||||
<string name="ends_at">מסתיים ב-%1$s</string>
|
||||
<string name="enter_server_url">הזן כתובת IP או URL</string>
|
||||
<string name="episodes">פרקים</string>
|
||||
<string name="external_track">חיצוני</string>
|
||||
<string name="file_size">גודל</string>
|
||||
<string name="genres">ז\'אנרים</string>
|
||||
<string name="go_to_series">עבור לסדרה</string>
|
||||
<string name="go_to">עבור אל</string>
|
||||
<string name="hide">הסתר</string>
|
||||
<string name="error_loading_collection">שגיאה בטעינת האוסף %1$s</string>
|
||||
<string name="intro">פתיח</string>
|
||||
<string name="library">תכנים</string>
|
||||
<string name="more">עוד</string>
|
||||
<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="outro">סיום</string>
|
||||
<string name="none">ללא</string>
|
||||
<plurals name="people">
|
||||
<item quantity="one">אנשים</item>
|
||||
<item quantity="two"></item>
|
||||
<item quantity="other"></item>
|
||||
</plurals>
|
||||
<string name="play_from_here">נגן מכאן</string>
|
||||
<string name="play">נגן</string>
|
||||
<string name="playback_speed">מהירות ניגון</string>
|
||||
<string name="playback">ניגון</string>
|
||||
<string name="playlist">פלייליסט</string>
|
||||
<string name="playlists">פלייליסטים</string>
|
||||
<plurals name="downloads">
|
||||
<item quantity="one">הורדה %s</item>
|
||||
<item quantity="two">%s הורדות</item>
|
||||
<item quantity="other">%s הורדות</item>
|
||||
</plurals>
|
||||
<plurals name="hours">
|
||||
<item quantity="one">שעה %d</item>
|
||||
<item quantity="two">שעתיים</item>
|
||||
<item quantity="other">%d שעות</item>
|
||||
</plurals>
|
||||
<plurals name="days">
|
||||
<item quantity="one">יום %s</item>
|
||||
<item quantity="two">יומיים</item>
|
||||
<item quantity="other">%s ימים</item>
|
||||
</plurals>
|
||||
<plurals name="items">
|
||||
<item quantity="one">פריט %d</item>
|
||||
<item quantity="two">%d פריטים</item>
|
||||
<item quantity="other">%d פריטים</item>
|
||||
</plurals>
|
||||
<plurals name="seconds">
|
||||
<item quantity="one">שניה %d</item>
|
||||
<item quantity="two">%d שניות</item>
|
||||
<item quantity="other">%d שניות</item>
|
||||
</plurals>
|
||||
<plurals name="minutes">
|
||||
<item quantity="one">דקה %s</item>
|
||||
<item quantity="two">%s דקות</item>
|
||||
<item quantity="other">%s דקות</item>
|
||||
</plurals>
|
||||
<string name="mark_unwatched">סמן כלא נצפה</string>
|
||||
<string name="mark_watched">סמן כנצפה</string>
|
||||
<string name="max_bitrate">מקסימום קצב נתונים</string>
|
||||
<string name="more_like_this">עוד דומים</string>
|
||||
</resources>
|
||||
|
|
|
|||
278
app/src/main/res/values-lv/strings.xml
Normal file
278
app/src/main/res/values-lv/strings.xml
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="about">Par</string>
|
||||
<string name="active_recordings">Aktīvie ieraksti</string>
|
||||
<string name="add_favorite">Izlase</string>
|
||||
<string name="add_server">Pievienot Serveri</string>
|
||||
<string name="add_user">Pievienot Lietotāju</string>
|
||||
<string name="audio">Audio</string>
|
||||
<string name="birthplace">Dzimšanas vieta</string>
|
||||
<string name="bitrate">Bitu ātrums</string>
|
||||
<string name="born">Dzimis</string>
|
||||
<string name="cancel_recording">Atcelt Ierakstu</string>
|
||||
<string name="cancel_series_recording">Atcelt Seriālu Ierakstu</string>
|
||||
<string name="cancel">Atcelt</string>
|
||||
<string name="chapters">Nodaļas</string>
|
||||
<string name="choose_stream">Izvēlēties %1$s</string>
|
||||
<string name="clear_image_cache">Dzēst attēlu kešatmiņu</string>
|
||||
<string name="collection">Kolekcija</string>
|
||||
<string name="collections">Kolekcijas</string>
|
||||
<string name="commercial">Reklāma</string>
|
||||
<string name="community_rating">Skatītāju vērtējums</string>
|
||||
<string name="compose_error_message_title">Radās kļūda! Nospiediet pogu, lai sūtītu žurnālus uz savu serveri.</string>
|
||||
<string name="confirm">Apstiprināt</string>
|
||||
<string name="continue_watching">Turpināt skatīties</string>
|
||||
<string name="critic_rating">Kritiķu vērtējums</string>
|
||||
<string name="decimal_seconds">%.2f sekundes</string>
|
||||
<string name="default_track">Noklusējums</string>
|
||||
<string name="delete">Dzēst</string>
|
||||
<string name="died">Miris</string>
|
||||
<string name="directed_by">Režisējis %1$s</string>
|
||||
<string name="director">Režisors</string>
|
||||
<string name="disabled">Atslēgts</string>
|
||||
<string name="discovered_servers">Atrastie serveri</string>
|
||||
<string name="download_and_update"><![CDATA[Lejupielādēt & Atjaunināt]]></string>
|
||||
<string name="downloading">Lejupielādē…</string>
|
||||
<string name="enabled">Ieslēgts</string>
|
||||
<string name="ends_at">Beigsies %1$s</string>
|
||||
<string name="enter_server_url">Ievadi Servera IP vai URL</string>
|
||||
<string name="enter_server_address">Ievadi servera adresi</string>
|
||||
<string name="episodes">Sērijas</string>
|
||||
<string name="error_loading_collection">Kļūda, ielādējot kolekciju %1$s</string>
|
||||
<string name="external_track">Ārējais</string>
|
||||
<string name="favorites">Izlases</string>
|
||||
<string name="file_size">Izmērs</string>
|
||||
<string name="forced_track">Piespiedu</string>
|
||||
<string name="genres">Žanri</string>
|
||||
<string name="go_to_series">Iet uz sērijām</string>
|
||||
<string name="go_to">Iet Uz</string>
|
||||
<string name="hide_controller_timeout">Paslēpt atskaņošanas kontroles</string>
|
||||
<string name="hide_debug_info">Paslēpt atkļūdošanas info</string>
|
||||
<string name="hide">Paslēpt</string>
|
||||
<string name="home">Sākums</string>
|
||||
<string name="immediate">Tūlītēja</string>
|
||||
<string name="intro">Ievads</string>
|
||||
<string name="library">Bibliotēka</string>
|
||||
<string name="license_info">Licences informācija</string>
|
||||
<string name="live_tv">Tiešraides</string>
|
||||
<string name="loading">Ielādē…</string>
|
||||
<string name="mark_entire_series_as_played">Atzīmēt visas sērijas kā redzētas?</string>
|
||||
<string name="mark_entire_series_as_unplayed">Atzīmēt visas sērijas kā neredzētas?</string>
|
||||
<string name="mark_unwatched">Atzīmēt kā neredzētu</string>
|
||||
<string name="mark_watched">Atzīmēt kā redzētu</string>
|
||||
<string name="max_bitrate">Maksimālais bitu ātrums</string>
|
||||
<string name="more_like_this">Līdzīgs saturs</string>
|
||||
<string name="more">Vairāk</string>
|
||||
<plurals name="movies">
|
||||
<item quantity="zero">Filmas</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<string name="name">Nosaukums</string>
|
||||
<string name="next_up">Rindā</string>
|
||||
<string name="no_data">Nav datu</string>
|
||||
<string name="no_results">Nav rezultātu</string>
|
||||
<string name="no_servers_found">Serveri netika atrasti</string>
|
||||
<string name="no_scheduled_recordings">Nav ieplānoti ieraksti</string>
|
||||
<string name="no_update_available">Nav pieejami atjauninājumi</string>
|
||||
<string name="none">Neviens</string>
|
||||
<string name="only_forced_subtitles">Tikai Piespiedu Subtitri</string>
|
||||
<string name="outro">Nobeigums</string>
|
||||
<string name="path">Ceļš</string>
|
||||
<plurals name="people">
|
||||
<item quantity="zero">Cilvēki</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<string name="play_count">Skatījumu skaits</string>
|
||||
<string name="play_from_here">Atskaņot no šejienes</string>
|
||||
<string name="play">Atskaņot</string>
|
||||
<string name="playback_debug_info">Rādīt atkļūdošanas informāciju</string>
|
||||
<string name="playback_speed">Atskaņošanas ātrums</string>
|
||||
<string name="playback">Atskaņošana</string>
|
||||
<string name="playlist">Saraksts</string>
|
||||
<string name="playlists">Saraksti</string>
|
||||
<string name="preview">Priekšskatījums</string>
|
||||
<string name="profile_specific_settings">Lietotāja Profila Iestatījumi</string>
|
||||
<string name="queue">Rinda</string>
|
||||
<string name="recap">Atskats</string>
|
||||
<string name="recently_added_in">Nesen pievienots sadaļā %1$s</string>
|
||||
<string name="recently_added">Nesen pievienots</string>
|
||||
<string name="recently_recorded">Nesenie Ieraksti</string>
|
||||
<string name="recently_released">Nesen Iznācis</string>
|
||||
<string name="recommended">Ieteicamie</string>
|
||||
<string name="record_program">Ierakstīt Raidījumu</string>
|
||||
<string name="record_series">Ierakstīt Sērijas</string>
|
||||
<string name="remove_favorite">Noņemt no izlases</string>
|
||||
<string name="restart">Restartēt</string>
|
||||
<string name="resume">Turpināt</string>
|
||||
<string name="save">Saglabāt</string>
|
||||
<string name="search_and_download"><![CDATA[Meklēt & Lejupielādēt]]></string>
|
||||
<string name="search">Meklēt</string>
|
||||
<string name="searching">Meklē…</string>
|
||||
<string name="voice_search">Balss meklēšana</string>
|
||||
<string name="voice_starting">Uzsāk</string>
|
||||
<string name="voice_search_prompt">Runā lai meklētu</string>
|
||||
<string name="processing">Apstrādā</string>
|
||||
<string name="press_back_to_cancel">Spied atpakaļ lai atceltu</string>
|
||||
<string name="voice_error_audio">Audio ieraksta kļūda</string>
|
||||
<string name="voice_error_client">Balss atpazīšanas kļūda</string>
|
||||
<string name="voice_error_permissions">Mikrofona tiesību kļūda</string>
|
||||
<string name="voice_error_network">Tīkla kļūda</string>
|
||||
<string name="voice_error_network_timeout">Tīkla noilgums</string>
|
||||
<string name="voice_error_no_match">Balss netika atpazīta</string>
|
||||
<string name="voice_error_busy">Balss atpazīšana aizņemta</string>
|
||||
<string name="voice_error_server">Servera kļūda</string>
|
||||
<string name="voice_error_speech_timeout">Neviena balss netika atklāta</string>
|
||||
<string name="voice_error_unknown">Nezināma kļūda</string>
|
||||
<string name="voice_error_start_failed">Kļūda sākot balss atpazīšanu</string>
|
||||
<string name="voice_error_timeout">Balss atpazīšanai iestājies noilgums</string>
|
||||
<string name="retry">Mēģināt vēlreiz</string>
|
||||
<string name="select_server">Izvēlies Serveri</string>
|
||||
<string name="select_user">Izvēlies Lietotāju</string>
|
||||
<string name="show_debug_info">Rādīt atkļūdošanas info</string>
|
||||
<string name="show_next_up_when">Rādīt \"Skaties tālāk\"</string>
|
||||
<string name="show">Rādīt</string>
|
||||
<string name="shuffle">Sajaukt</string>
|
||||
<string name="skip">Izlaist</string>
|
||||
<string name="sort_by_date_added">Pievienošanas Datums</string>
|
||||
<string name="sort_by_date_episode_added">Sērijas pievienošanas datums</string>
|
||||
<string name="sort_by_date_played">Atskaņošanas Datums</string>
|
||||
<string name="sort_by_date_released">Izdošanas Datums</string>
|
||||
<string name="sort_by_name">Nosaukums</string>
|
||||
<string name="sort_by_random">Nejaušs</string>
|
||||
<string name="studios">Studija</string>
|
||||
<string name="submit">Iesniegt</string>
|
||||
<string name="subtitle_download_too_long">Lejupielāde ir lēna, iespējams jāmēģina restartēt video</string>
|
||||
<string name="subtitle">Subtitri</string>
|
||||
<string name="subtitles">Subtitri</string>
|
||||
<string name="suggestions">Ieteikumi</string>
|
||||
<string name="switch_servers">Mainīt serverus</string>
|
||||
<string name="switch_user">Pārslēgt</string>
|
||||
<string name="top_unwatched">Augstāk novērtētie un neredzētie</string>
|
||||
<string name="trailer">Treileris</string>
|
||||
<plurals name="trailers">
|
||||
<item quantity="zero">Treileri</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<string name="tv_dvr_schedule">Ierakstu plāns</string>
|
||||
<string name="tv_guide">Programma</string>
|
||||
<string name="tv_season">Sezona</string>
|
||||
<string name="tv_seasons">Sezonas</string>
|
||||
<plurals name="tv_shows">
|
||||
<item quantity="zero">TV Šovi</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<string name="ui_interface">Saskarne</string>
|
||||
<string name="unknown">Nezināms</string>
|
||||
<string name="updates">Atjauninājumi</string>
|
||||
<string name="version">Versija</string>
|
||||
<string name="video_scale">Video Mērogs</string>
|
||||
<string name="video">Video</string>
|
||||
<string name="videos">Video</string>
|
||||
<string name="watch_live">Skatīties tiešraidi</string>
|
||||
<string name="years_old">%1$d gadus vecs</string>
|
||||
<string name="play_with_transcoding">Atskaņot ar transkodēšanu</string>
|
||||
<string name="media_information">Informācija par failu</string>
|
||||
<string name="show_clock">Rādīt Pulksteni</string>
|
||||
<string name="aired_episode_order">Iznākušo Sēriju Secība</string>
|
||||
<string name="create_playlist">Izveidot jaunu izlasi</string>
|
||||
<string name="add_to_playlist">Pievienot izlasei</string>
|
||||
<string name="one_click_pause">Apturēt ar vienu klikšķi</string>
|
||||
<string name="one_click_pause_summary_on">Nospiediet pults vidu, lai pauzētu/atskaņotu</string>
|
||||
<string name="italic_font">Slīpraksts</string>
|
||||
<string name="font">Teksts</string>
|
||||
<string name="background">Fons</string>
|
||||
<string name="success">Veiksmīgi</string>
|
||||
<string name="official_rating">Vecuma ierobežojums</string>
|
||||
<string name="runtime_sort">Ilgums</string>
|
||||
<string name="extras">Ekstras</string>
|
||||
<plurals name="other_extras">
|
||||
<item quantity="zero">Cits</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<plurals name="behind_the_scenes">
|
||||
<item quantity="zero">Aizkadri</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<plurals name="theme_songs">
|
||||
<item quantity="zero">Tituldziesmas</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<plurals name="theme_videos">
|
||||
<item quantity="zero">Titulvideo</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<plurals name="clips">
|
||||
<item quantity="zero">Klipi</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<plurals name="deleted_scenes">
|
||||
<item quantity="zero">Izgrieztās ainas</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<plurals name="interviews">
|
||||
<item quantity="zero">Intervijas</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<plurals name="scenes">
|
||||
<item quantity="zero">Ainas</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<plurals name="samples">
|
||||
<item quantity="zero">Paraugi</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<plurals name="featurettes">
|
||||
<item quantity="zero">Īssižeti</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<plurals name="shorts">
|
||||
<item quantity="zero">Īsie rullīši</item>
|
||||
<item quantity="one"/>
|
||||
<item quantity="other"/>
|
||||
</plurals>
|
||||
<string name="favorite_items">Izlase %s</string>
|
||||
<plurals name="downloads">
|
||||
<item quantity="zero">%s lejupielāžu</item>
|
||||
<item quantity="one">%s lejupielāde</item>
|
||||
<item quantity="other">%s lejupielādes</item>
|
||||
</plurals>
|
||||
<plurals name="hours">
|
||||
<item quantity="zero">%d stundas</item>
|
||||
<item quantity="one">%d stunda</item>
|
||||
<item quantity="other">%d stundas</item>
|
||||
</plurals>
|
||||
<plurals name="days">
|
||||
<item quantity="zero">%s dienas</item>
|
||||
<item quantity="one">%s diena</item>
|
||||
<item quantity="other">%s dienas</item>
|
||||
</plurals>
|
||||
<plurals name="items">
|
||||
<item quantity="zero">%d elementi</item>
|
||||
<item quantity="one">%d elements</item>
|
||||
<item quantity="other">%d elementi</item>
|
||||
</plurals>
|
||||
<plurals name="seconds">
|
||||
<item quantity="zero">%d sekundes</item>
|
||||
<item quantity="one">%d sekunde</item>
|
||||
<item quantity="other">%d sekundes</item>
|
||||
</plurals>
|
||||
<plurals name="minutes">
|
||||
<item quantity="zero">%s minūtes</item>
|
||||
<item quantity="one">%s minūte</item>
|
||||
<item quantity="other">%s minūtes</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<resources>
|
||||
<string name="about">Over</string>
|
||||
<string name="active_recordings">Actieve opnames</string>
|
||||
<string name="add_favorite">Toev. aan favorieten</string>
|
||||
<string name="add_favorite">Aan favorieten toevoegen</string>
|
||||
<string name="add_server">Server toevoegen</string>
|
||||
<string name="add_user">Gebruiker toevoegen</string>
|
||||
<string name="audio">Audio</string>
|
||||
|
|
@ -18,11 +18,11 @@
|
|||
<string name="collection">Collectie</string>
|
||||
<string name="collections">Collecties</string>
|
||||
<string name="commercial">Reclame</string>
|
||||
<string name="community_rating">Gemeenschapsbeoordeling</string>
|
||||
<string name="community_rating">Beoordeling (publiek)</string>
|
||||
<string name="compose_error_message_title">Er is een fout opgetreden. Druk op de knop om de logboeken naar je server te versturen.</string>
|
||||
<string name="confirm">Bevestigen</string>
|
||||
<string name="continue_watching">Hervatten</string>
|
||||
<string name="critic_rating">Critici-beoordeling</string>
|
||||
<string name="continue_watching">Verder kijken</string>
|
||||
<string name="critic_rating">Beoordeling (recensie)</string>
|
||||
<string name="decimal_seconds">%.1f seconden</string>
|
||||
<string name="default_track">Standaard</string>
|
||||
<string name="delete">Verwijderen</string>
|
||||
|
|
@ -30,24 +30,24 @@
|
|||
<string name="directed_by">Geregisseerd door %1$s</string>
|
||||
<string name="director">Regisseur</string>
|
||||
<string name="disabled">Uitgeschakeld</string>
|
||||
<string name="discovered_servers">Aangetroffen servers</string>
|
||||
<string name="discovered_servers">Gevonden servers</string>
|
||||
<string name="download_and_update"><![CDATA[Downloaden en bijwerken]]></string>
|
||||
<string name="downloading">Bezig met downloaden…</string>
|
||||
<string name="enabled">Ingeschakeld</string>
|
||||
<string name="enter_server_url">Voer het IP-adres of de url van de server in</string>
|
||||
<string name="enter_server_url">Voer IP-adres of url van server in</string>
|
||||
<string name="episodes">Afleveringen</string>
|
||||
<string name="error_loading_collection">‘%1$s’ kan niet worden geladen</string>
|
||||
<string name="error_loading_collection">Fout bij laden %1$s collectie</string>
|
||||
<string name="external_track">Extern</string>
|
||||
<string name="favorites">Favorieten</string>
|
||||
<string name="file_size">Grootte</string>
|
||||
<string name="forced_track">Afgedwongen</string>
|
||||
<string name="forced_track">Geforceerd</string>
|
||||
<string name="genres">Genres</string>
|
||||
<string name="go_to_series">Ga naar serie</string>
|
||||
<string name="go_to">Ga naar</string>
|
||||
<string name="hide_controller_timeout">Afspeelbediening verbergen</string>
|
||||
<string name="hide_debug_info">Foutopsporingsinformatie verbergen</string>
|
||||
<string name="hide">Verbergen</string>
|
||||
<string name="home">Overzicht</string>
|
||||
<string name="home">Home</string>
|
||||
<string name="immediate">Onmiddellijk</string>
|
||||
<string name="intro">Intro</string>
|
||||
<string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string>
|
||||
|
|
@ -55,16 +55,16 @@
|
|||
<string name="license_info">Licentie-informatie</string>
|
||||
<string name="live_tv">Live-tv</string>
|
||||
<string name="loading">Bezig met laden…</string>
|
||||
<string name="mark_entire_series_as_played">Wil je de gehele serie als bekeken markeren?</string>
|
||||
<string name="mark_entire_series_as_unplayed">Wil je de gehele serie als niet-bekeken markeren?</string>
|
||||
<string name="mark_entire_series_as_played">Serie als bekeken markeren?</string>
|
||||
<string name="mark_entire_series_as_unplayed">Serie als niet-bekeken markeren?</string>
|
||||
<string name="mark_unwatched">Markeren als niet-bekeken</string>
|
||||
<string name="mark_watched">Markeren als bekeken</string>
|
||||
<string name="max_bitrate">Max. bitsnelheid</string>
|
||||
<string name="more_like_this">Gerelateerd</string>
|
||||
<string name="more_like_this">Soortgelijk</string>
|
||||
<string name="more">Meer</string>
|
||||
<string name="movies">Films</string>
|
||||
<string name="name">Naam</string>
|
||||
<string name="next_up">Hierna</string>
|
||||
<string name="next_up">Volgende</string>
|
||||
<string name="no_data">Geen informatie</string>
|
||||
<string name="no_results">Geen zoekresultaten</string>
|
||||
<string name="no_scheduled_recordings">Geen ingeplande opnames</string>
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
<string name="path">Locatie</string>
|
||||
<string name="people">Personen</string>
|
||||
<string name="play_count">Aantal keer bekeken</string>
|
||||
<string name="play_from_here">Hervatten vanaf hier</string>
|
||||
<string name="play_from_here">Vanaf hier afspelen</string>
|
||||
<string name="play">Afspelen</string>
|
||||
<string name="playback_debug_info">Foutopsporingsinformatie tonen</string>
|
||||
<string name="playback_speed">Afspeelsnelheid</string>
|
||||
|
|
@ -84,21 +84,21 @@
|
|||
<string name="preview">Voorvertoning</string>
|
||||
<string name="profile_specific_settings">Profielinstellingen</string>
|
||||
<string name="queue">Wachtrij</string>
|
||||
<string name="recap">Samenvatten</string>
|
||||
<string name="recently_added_in">Onlangs toegevoegd aan ‘%1$s’</string>
|
||||
<string name="recap">Samenvatting</string>
|
||||
<string name="recently_added_in">Onlangs toegevoegd aan %1$s</string>
|
||||
<string name="recently_added">Onlangs toegevoegd</string>
|
||||
<string name="recently_recorded">Onlangs opgenomen</string>
|
||||
<string name="recently_released">Onlangs uitgebracht</string>
|
||||
<string name="recommended">Aanbevolen</string>
|
||||
<string name="record_program">Programma opnemen</string>
|
||||
<string name="record_series">Serie opnemen</string>
|
||||
<string name="remove_favorite">Verw. uit favorieten</string>
|
||||
<string name="remove_favorite">Uit favorieten verwijderen</string>
|
||||
<string name="restart">Herstarten</string>
|
||||
<string name="resume">Hervatten</string>
|
||||
<string name="save">Opslaan</string>
|
||||
<string name="search_and_download"><![CDATA[Zoeken en downloaden]]></string>
|
||||
<string name="search">Zoeken</string>
|
||||
<string name="searching">Bezig met zoeken…</string>
|
||||
<string name="searching">Aan het zoeken…</string>
|
||||
<string name="select_server">Kies een server</string>
|
||||
<string name="select_user">Kies een gebruiker</string>
|
||||
<string name="show_debug_info">Foutopsporingsinformatie tonen</string>
|
||||
|
|
@ -106,10 +106,10 @@
|
|||
<string name="show">Tonen</string>
|
||||
<string name="shuffle">Willekeurig</string>
|
||||
<string name="skip">Overslaan</string>
|
||||
<string name="sort_by_date_added">Toegevoegd op</string>
|
||||
<string name="sort_by_date_episode_added">Toegevoegd op (aflevering)</string>
|
||||
<string name="sort_by_date_played">Bekeken op</string>
|
||||
<string name="sort_by_date_released">Uitgebracht op</string>
|
||||
<string name="sort_by_date_added">Toegevoegd</string>
|
||||
<string name="sort_by_date_episode_added">Toegevoegd (aflevering)</string>
|
||||
<string name="sort_by_date_played">Bekeken</string>
|
||||
<string name="sort_by_date_released">Uitgebracht</string>
|
||||
<string name="sort_by_name">Naam</string>
|
||||
<string name="sort_by_random">Willekeurig</string>
|
||||
<string name="studios">Studio\'s</string>
|
||||
|
|
@ -130,7 +130,7 @@
|
|||
<string name="tv_guide">Gids</string>
|
||||
<string name="tv_season">Seizoen</string>
|
||||
<string name="tv_seasons">Seizoenen</string>
|
||||
<string name="tv_shows">Tv-shows</string>
|
||||
<string name="tv_shows">Afleveringen</string>
|
||||
<string name="ui_interface">Vormgeving</string>
|
||||
<string name="unknown">Onbekend</string>
|
||||
<string name="updates">Updates</string>
|
||||
|
|
@ -144,14 +144,14 @@
|
|||
<string name="show_clock">Klok tonen</string>
|
||||
<string name="aired_episode_order">Volgorde van uitgezonden afleveringen</string>
|
||||
<string name="create_playlist">Afspeellijst maken</string>
|
||||
<string name="add_to_playlist">Toevoegen aan afspeellijst</string>
|
||||
<string name="add_to_playlist">Aan afspeellijst toevoegen</string>
|
||||
<string name="one_click_pause">Pauzeren met één druk op de knop</string>
|
||||
<string name="one_click_pause_summary_on">Druk op de middelste knop van het d-pad om af te spelen/te pauzeren</string>
|
||||
<string name="italic_font">Cursief lettertype gebruiken</string>
|
||||
<string name="font">Lettertype</string>
|
||||
<string name="background">Achtergrond</string>
|
||||
<string name="success">Voltooid</string>
|
||||
<string name="official_rating">Kijkwijzerbeoordeling</string>
|
||||
<string name="official_rating">Kijkwijzer</string>
|
||||
<string name="runtime_sort">Duur</string>
|
||||
<string name="extras">Extra\'s</string>
|
||||
<plurals name="other_extras">
|
||||
|
|
@ -354,4 +354,91 @@
|
|||
<string name="favorite_channels_at_beginning">Favoriete kanalen eerst tonen</string>
|
||||
<string name="sort_channels_recently_watched">Kanalen sorteren op onlangs bekeken</string>
|
||||
<string name="color_code_programs">Programma\'s voorzien van kleurcode</string>
|
||||
<string name="ends_at">Eindigt om %1$s</string>
|
||||
<string name="enter_server_address">Voer server-adres in</string>
|
||||
<string name="discover">Ontdekken</string>
|
||||
<string name="rotate_left">Draai naar links</string>
|
||||
<string name="rotate_right">Draai naar rechts</string>
|
||||
<string name="zoom_out">Zoom uit</string>
|
||||
<string name="skip_ignore">Negeren</string>
|
||||
<string name="skip_ask">Vraag om overslaan</string>
|
||||
<string name="favorite_items">Favoriete %s</string>
|
||||
<string name="duration">Duur</string>
|
||||
<string name="add_row">Rij toevoegen</string>
|
||||
<string name="for_all_ages">Alle leeftijden</string>
|
||||
<string name="username">Gebruikersnaam</string>
|
||||
<string name="up_to_age">Leeftijd tot %s</string>
|
||||
<string name="saturation">Verzadiging</string>
|
||||
<string name="guest_star">Gast hoofdrol</string>
|
||||
<string name="remove_from_queue">Verwijder uit wachtrij</string>
|
||||
<string name="seerr_login">Login bij Seerr server</string>
|
||||
<string name="search_for">Zoek %s</string>
|
||||
<string name="actor">Acteur</string>
|
||||
<string name="clear_track_choices">Afspeel voorkeuren wissen</string>
|
||||
<string name="display_preset_episode_thumbnails">Aflevering thumbnails</string>
|
||||
<string name="writer">Auteur</string>
|
||||
<string name="for_episodes">Voor afleveringen</string>
|
||||
<string name="start_after">Start na</string>
|
||||
<string name="add_to_queue">Toevoegen aan wachtrij</string>
|
||||
<string name="composer">Componist</string>
|
||||
<string name="display_preset_series_thumb">Serie thumbnails</string>
|
||||
<string name="delete_item">Weet u zeker dat u dit item wilt verwijderen?</string>
|
||||
<string name="settings_saved">Instellingen opgeslagen</string>
|
||||
<string name="continue_string">Ga door</string>
|
||||
<string name="music_videos">Muziekvideos</string>
|
||||
<string name="max_days_next_up">Max. aantal dagen in Volgende</string>
|
||||
<string name="now_playing">Wordt nu afgespeeld</string>
|
||||
<string name="request">Aanvragen</string>
|
||||
<string name="play_trailer">Trailer afspelen</string>
|
||||
<string name="no_servers_found">Geen servers gevonden</string>
|
||||
<string name="only_forced_subtitles">Alleen geforceerde ondertiteling</string>
|
||||
<string name="retry">Opnieuw proberen</string>
|
||||
<string name="subtitle_delay">Vertraging ondertiteling</string>
|
||||
<string name="no_trailers">Geen trailers</string>
|
||||
<string name="pending">In behandeling</string>
|
||||
<string name="password">Wachtwoord</string>
|
||||
<string name="upcoming_movies">Verwachte films</string>
|
||||
<string name="upcoming_tv">Verwachte series</string>
|
||||
<string name="request_4k">Verzoek in 4K</string>
|
||||
<string name="brightness">Helderheid</string>
|
||||
<string name="send_media_info_log_to_server">Stuur media-info log naar server</string>
|
||||
<string name="resolution_switching">Resolutie schakelen</string>
|
||||
<string name="local">Lokaal</string>
|
||||
<string name="backdrop_display">Achtergrondstijl</string>
|
||||
<string name="force_dovi_profile_7_summary">Negeer compatibiliteitscontrole apparaat</string>
|
||||
<string name="voice_search">Gesproken zoekopdracht</string>
|
||||
<string name="voice_search_prompt">Spreek om te zoeken</string>
|
||||
<string name="voice_starting">Starten</string>
|
||||
<string name="processing">Verwerken</string>
|
||||
<string name="press_back_to_cancel">Terugknop om te annuleren</string>
|
||||
<string name="voice_error_audio">Audio opnamefout</string>
|
||||
<string name="voice_error_client">Spraakherkenningsfout</string>
|
||||
<string name="voice_error_permissions">Toestemming microfoon vereist</string>
|
||||
<string name="voice_error_network">Netwerkfout</string>
|
||||
<string name="voice_error_network_timeout">Netwerk timeout</string>
|
||||
<string name="voice_error_no_match">Geen spraak herkent</string>
|
||||
<string name="voice_error_busy">Spraakherkenning bezig</string>
|
||||
<string name="voice_error_server">Serverfout</string>
|
||||
<string name="voice_error_speech_timeout">Geen spraak gedetecteerd</string>
|
||||
<string name="voice_error_unknown">Onbekende fout</string>
|
||||
<string name="voice_error_start_failed">Fout bij starten spraakherkenning</string>
|
||||
<string name="voice_error_timeout">Spraakherkenning timed out</string>
|
||||
<string name="seerr_integration">Seerr integratie</string>
|
||||
<string name="remove_seerr_server">Verwijder Seerr server</string>
|
||||
<string name="seerr_server_added">Seerr server toegevoegd</string>
|
||||
<string name="hdr_subtitle_style">HDR ondertitelstijl</string>
|
||||
<string name="image_subtitle_opacity">Dekking ondertitelbeeld</string>
|
||||
<string name="zoom_in">Zoom in</string>
|
||||
<string name="no_limit">Geen limiet</string>
|
||||
<string name="customize_home">Startscherm aanpassen</string>
|
||||
<string name="home_rows">Startrijen</string>
|
||||
<string name="suggestions_for">"Aanbevelingen voor %1$s"</string>
|
||||
<string name="volume_lowest">Laagst</string>
|
||||
<string name="skip_automatically">Automatisch overslaan</string>
|
||||
<string name="conductor">Dirigent</string>
|
||||
<string name="producer">Producent</string>
|
||||
<plurals name="days">
|
||||
<item quantity="one">%s dag</item>
|
||||
<item quantity="other">%s dagen</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -359,4 +359,188 @@
|
|||
</plurals>
|
||||
<string name="nav_drawer_pins_summary">Vel standardelementa som skal visast, andre vert skjulde</string>
|
||||
<string name="ends_at">Sluttar %1$s</string>
|
||||
<string name="only_forced_subtitles">Berre påtvinga undertekstar</string>
|
||||
<string name="voice_search">Stemmesøk</string>
|
||||
<string name="voice_starting">Startar</string>
|
||||
<string name="voice_search_prompt">Snakk for å søkje</string>
|
||||
<string name="processing">Jobbar</string>
|
||||
<string name="press_back_to_cancel">Trykk tilbake for å avbryta</string>
|
||||
<string name="voice_error_audio">Lydopptaksfeil</string>
|
||||
<string name="voice_error_client">Feil med talegjenkjenning</string>
|
||||
<string name="voice_error_permissions">Treng mikrofontilgang</string>
|
||||
<string name="voice_error_network">Nettverksfeil</string>
|
||||
<string name="voice_error_network_timeout">Tidsavbrot på nettverket</string>
|
||||
<string name="voice_error_no_match">Fann inga tale</string>
|
||||
<string name="voice_error_busy">Talegjenkjenning er oppteken</string>
|
||||
<string name="voice_error_server">Tenarfeil</string>
|
||||
<string name="voice_error_speech_timeout">Inga tale funne</string>
|
||||
<string name="voice_error_unknown">Ukjend feil</string>
|
||||
<string name="voice_error_start_failed">Klarte ikkje starta talegjenkjenning</string>
|
||||
<string name="voice_error_timeout">Tidsavbrot for talegjenkjenning</string>
|
||||
<string name="retry">Prøv på nytt</string>
|
||||
<string name="favorite_items">Favoritt %s</string>
|
||||
<string name="resolution_switching">Oppløysingsbyte</string>
|
||||
<string name="local">Lokal</string>
|
||||
<string name="play_trailer">Spel trailer</string>
|
||||
<string name="no_trailers">Ingen trailerar</string>
|
||||
<string name="clear_track_choices">Tøm sporval</string>
|
||||
<string name="dts_x">DTS:X</string>
|
||||
<string name="dts_hd">DTS:HD</string>
|
||||
<string name="dts_hd_ma">DTS:HD MA</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">Direkteavspeling av Dolby Vision-profil 7</string>
|
||||
<string name="force_dovi_profile_7_summary">Ignorerer kompatibilitetssjekk på eininga</string>
|
||||
<string name="discover">Oppdag</string>
|
||||
<string name="request">Be om</string>
|
||||
<string name="pending">Ventar</string>
|
||||
<string name="seerr_integration">Seerr-integrasjon</string>
|
||||
<string name="remove_seerr_server">Fjern Seerr-tenar</string>
|
||||
<string name="seerr_server_added">Seerr-tenar lagt til</string>
|
||||
<string name="quick_connect">Hurtigkopling</string>
|
||||
<string name="quick_connect_summary">Gi ei anna eining tilgang til å logge inn på kontoen din</string>
|
||||
<string name="quick_connect_code">Skriv inn hurtigkoplingskode</string>
|
||||
<string name="quick_connect_code_error">Koden må vere 6 siffer</string>
|
||||
<string name="quick_connect_success">Eininga fekk tilgang</string>
|
||||
<string name="password">Passord</string>
|
||||
<string name="username">Brukarnamn</string>
|
||||
<string name="url">URL</string>
|
||||
<string name="trending">Populært no</string>
|
||||
<string name="upcoming_movies">Kommande filmar</string>
|
||||
<string name="upcoming_tv">Kommande TV-seriar</string>
|
||||
<string name="request_4k">Be om i 4K</string>
|
||||
<string name="software_decoding_av1">AV1-programvaredekoding</string>
|
||||
<string name="hdr_subtitle_style">HDR-tekststil</string>
|
||||
<string name="image_subtitle_opacity">Gjennomsiktigheit for bilettekst</string>
|
||||
<string name="brightness">Lysstyrke</string>
|
||||
<string name="contrast">Kontrast</string>
|
||||
<string name="saturation">Metning</string>
|
||||
<string name="hue">Fargetone</string>
|
||||
<string name="red">Raud</string>
|
||||
<string name="green">Grøn</string>
|
||||
<string name="blue">Blå</string>
|
||||
<string name="blur">Sløring</string>
|
||||
<string name="save_for_album">Lagre til album</string>
|
||||
<string name="play_slideshow">Spel lysbiletevising</string>
|
||||
<string name="stop_slideshow">Stopp lysbiletevising</string>
|
||||
<string name="slideshow_at_beginning">Ved byrjinga</string>
|
||||
<string name="no_more_images">Ingen fleire bilete</string>
|
||||
<string name="rotate_left">Roter til venstre</string>
|
||||
<string name="rotate_right">Roter til høgre</string>
|
||||
<string name="zoom_in">Zoom inn</string>
|
||||
<string name="zoom_out">Zoom ut</string>
|
||||
<string name="slideshow_duration">Lengde på lysbiletevising</string>
|
||||
<string name="play_videos_during_slideshow">Spel videoar i lysbiletevisinga</string>
|
||||
<string name="send_media_info_log_to_server">Send logg med medieinfo til tenaren</string>
|
||||
<string name="no_limit">Ingen grense</string>
|
||||
<string name="max_days_next_up">Maks dagar i Neste</string>
|
||||
<string name="add_row">Legg til rad</string>
|
||||
<string name="genres_in">Sjangrar i %1$s</string>
|
||||
<string name="recently_released_in">Nylig utgitt i %1$s</string>
|
||||
<string name="height">Høgde</string>
|
||||
<string name="apply_all_rows">Bruk på alle radene</string>
|
||||
<string name="customize_home">Tilpass heim-sida</string>
|
||||
<string name="home_rows">Rader på heim-sida</string>
|
||||
<string name="load_from_server">Last frå brukarprofil på tenaren</string>
|
||||
<string name="save_to_server">Lagre til brukarprofil på tenaren</string>
|
||||
<string name="load_from_web_client">Last frå nettklient</string>
|
||||
<string name="use_series">Bruk bilete av serien</string>
|
||||
<string name="add_row_for">Legg til rad for %1$s</string>
|
||||
<string name="overwrite_server_settings">Overskrive innstillingane på tenaren?</string>
|
||||
<string name="overwrite_local_settings">Overskrive lokale innstillingar?</string>
|
||||
<string name="for_episodes">For episodar</string>
|
||||
<string name="suggestions_for">Forslag for %1$s</string>
|
||||
<string name="increase_all_cards_size">Auk storleiken på alle korta</string>
|
||||
<string name="decrease_all_cards_size">Mink storleiken på alle korta</string>
|
||||
<string name="use_thumb_images">Bruk miniatyrbilete</string>
|
||||
<string name="settings_saved">Innstillingar lagra</string>
|
||||
<string name="display_presets">Visingsval</string>
|
||||
<string name="display_presets_description">Innebygde val for å raskt endre stilen på alle radene</string>
|
||||
<string name="customize_home_summary">Vel rader og bilete på heim-sida</string>
|
||||
<string name="start_after">Start etter</string>
|
||||
<string name="animate">Animer</string>
|
||||
<string name="max_age_rating">Maks aldersgrense</string>
|
||||
<string name="no_max">Ingen maksgrense</string>
|
||||
<string name="for_all_ages">For alle aldrar</string>
|
||||
<string name="up_to_age">Opp til %s år</string>
|
||||
<string name="screensaver">Skjermsparar</string>
|
||||
<string name="screensaver_settings">Innstillingar for skjermsparar</string>
|
||||
<string name="start_screensaver">Start skjermsparar</string>
|
||||
<string name="duration">Varigheit</string>
|
||||
<string name="photos">Bilete</string>
|
||||
<string name="include_types">Inkluder typar</string>
|
||||
<string name="include_types_summary">Kva typar element det skal visast bilete for</string>
|
||||
<string name="content_scale_fit">Tilpass</string>
|
||||
<string name="content_scale_crop">Beskjer</string>
|
||||
<string name="content_scale_fill">Fyll</string>
|
||||
<string name="content_scale_fill_width">Fyll breidde</string>
|
||||
<string name="content_scale_fill_height">Fyll høgde</string>
|
||||
<string name="display_preset_default">Wholphin-standard</string>
|
||||
<string name="display_preset_compact">Wholphin-kompakt</string>
|
||||
<string name="display_preset_series_thumb">Miniatyrbilete for seriar</string>
|
||||
<string name="display_preset_episode_thumbnails">Miniatyrbilete for episodar</string>
|
||||
<string name="volume_lowest">Lågast</string>
|
||||
<string name="volume_low">Låg</string>
|
||||
<string name="volume_medium">Middels</string>
|
||||
<string name="volume_high">Høg</string>
|
||||
<string name="volume_full">Full</string>
|
||||
<string name="purple">Lilla</string>
|
||||
<string name="orange">Oransje</string>
|
||||
<string name="bold_blue">Kraftig blå</string>
|
||||
<string name="black">Svart</string>
|
||||
<string name="skip_ignore">Ignorer</string>
|
||||
<string name="skip_automatically">Hopp over automatisk</string>
|
||||
<string name="skip_ask">Spør om å hoppe over</string>
|
||||
<string name="ffmpeg_fallback">Berre bruk FFmpeg om det ikkje finst innebygd dekodar</string>
|
||||
<string name="ffmpeg_prefer">Føretrekk FFmpeg framfor innebygde dekodarar</string>
|
||||
<string name="ffmpeg_never">Aldri bruk FFmpeg-dekodarar</string>
|
||||
<string name="next_up_playback_end">Ved slutten av avspelinga</string>
|
||||
<string name="next_up_outro">Under rulletekst/outro</string>
|
||||
<string name="white">Kvit</string>
|
||||
<string name="light_gray">Lysegrå</string>
|
||||
<string name="dark_gray">Mørkegrå</string>
|
||||
<string name="yellow">Gul</string>
|
||||
<string name="cyan">Cyan</string>
|
||||
<string name="magenta">Magenta</string>
|
||||
<string name="subtitle_edge_outline">Omriss</string>
|
||||
<string name="subtitle_edge_shadow">Skugge</string>
|
||||
<string name="background_style_wrap">Omkransa</string>
|
||||
<string name="background_style_boxed">Rektangulær</string>
|
||||
<string name="prefer_mpv">Føretrekk MPV</string>
|
||||
<string name="player_backend_options_subtitles_prefer_mpv">Bruk ExoPlayer for HDR-avspeling</string>
|
||||
<string name="aspect_ratios_poster">Plakat (2:3)</string>
|
||||
<string name="aspect_ratios_16_9">16:9</string>
|
||||
<string name="aspect_ratios_4_3">4:3</string>
|
||||
<string name="aspect_ratios_square">Kvadrat (1:1)</string>
|
||||
<string name="image_type_primary">Primær</string>
|
||||
<string name="image_type_thumb">Miniatyr</string>
|
||||
<string name="backdrop_style_dynamic">Bilete med dynamisk farge</string>
|
||||
<string name="backdrop_style_image">Berre bilete</string>
|
||||
<string name="enter_url_api_key"><![CDATA[Skriv inn URL og API-nøkkel]]></string>
|
||||
<string name="api_key">API-nøkkel</string>
|
||||
<string name="seerr_login">Logg inn på Seerr-tenar</string>
|
||||
<string name="seerr_jellyfin_user">Jellyfin-brukar</string>
|
||||
<string name="seerr_local_user">Lokal brukar</string>
|
||||
<string name="search_and_download_subtitles"><![CDATA[Søk og last ned undertekstar]]></string>
|
||||
<string name="no_subtitles_found">Fann ingen eksterne undertekstar</string>
|
||||
<string name="series_continueing">No</string>
|
||||
<string name="in_app_screensaver">Bruk skjermsparar i appen</string>
|
||||
<string name="delete_item">Er du sikker på at du vil sletta dette elementet?</string>
|
||||
<string name="show_media_management">Vis alternativ for mediehandsaming</string>
|
||||
<string name="actor">Skodespelar</string>
|
||||
<string name="composer">Komponist</string>
|
||||
<string name="writer">Forfattar</string>
|
||||
<string name="guest_star">Gjestestjerne</string>
|
||||
<string name="producer">Produsent</string>
|
||||
<string name="conductor">Dirigent</string>
|
||||
<string name="lyricist">Tekstforfattar</string>
|
||||
<string name="arranger">Arrangør</string>
|
||||
<string name="engineer">Teknikar</string>
|
||||
<string name="mixer">Miksar</string>
|
||||
<string name="creator">Skapar</string>
|
||||
<string name="artist">Artist</string>
|
||||
<string name="search_for">Søk %s</string>
|
||||
<string name="select_all">Vel alle</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -64,9 +64,9 @@
|
|||
<string name="more_like_this">Mais como isso</string>
|
||||
<string name="more">Mais</string>
|
||||
<plurals name="movies">
|
||||
<item quantity="one">Filmes</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="one">Filme</item>
|
||||
<item quantity="many">Filmes</item>
|
||||
<item quantity="other">Filmes</item>
|
||||
</plurals>
|
||||
<string name="name">Nome</string>
|
||||
<string name="next_up">A Seguir</string>
|
||||
|
|
@ -80,9 +80,9 @@
|
|||
<string name="outro">Finalização</string>
|
||||
<string name="path">Caminho</string>
|
||||
<plurals name="people">
|
||||
<item quantity="one">Pessoas</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="one">Pessoa</item>
|
||||
<item quantity="many">Pessoas</item>
|
||||
<item quantity="other">Pessoas</item>
|
||||
</plurals>
|
||||
<string name="play_count">Contagem de Reproduções</string>
|
||||
<string name="play_from_here">Reproduzir a partir daqui</string>
|
||||
|
|
@ -151,18 +151,18 @@
|
|||
<string name="top_unwatched">Não assistidos com melhor avaliação</string>
|
||||
<string name="trailer">Trailer</string>
|
||||
<plurals name="trailers">
|
||||
<item quantity="one">Trailers</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="one">Trailer</item>
|
||||
<item quantity="many">Trailers</item>
|
||||
<item quantity="other">Trailers</item>
|
||||
</plurals>
|
||||
<string name="tv_dvr_schedule">Agenda do DVR</string>
|
||||
<string name="tv_guide">Guia</string>
|
||||
<string name="tv_season">Temporada</string>
|
||||
<string name="tv_seasons">Temporadas</string>
|
||||
<plurals name="tv_shows">
|
||||
<item quantity="one">Programas de TV</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="one">Programa de TV</item>
|
||||
<item quantity="many">Programas de TV</item>
|
||||
<item quantity="other">Programas de TV</item>
|
||||
</plurals>
|
||||
<string name="ui_interface">Interface</string>
|
||||
<string name="unknown">Desconhecido</string>
|
||||
|
|
@ -190,53 +190,53 @@
|
|||
<string name="extras">Extras</string>
|
||||
<plurals name="other_extras">
|
||||
<item quantity="one">Outro</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="many">Outros</item>
|
||||
<item quantity="other">Outros</item>
|
||||
</plurals>
|
||||
<plurals name="behind_the_scenes">
|
||||
<item quantity="one">Por Trás das Cenas</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="many">Por Trás das Cenas</item>
|
||||
<item quantity="other">Por Trás das Cenas</item>
|
||||
</plurals>
|
||||
<plurals name="theme_songs">
|
||||
<item quantity="one">Músicas Tema</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="one">Música Tema</item>
|
||||
<item quantity="many">Músicas Tema</item>
|
||||
<item quantity="other">Músicas Tema</item>
|
||||
</plurals>
|
||||
<plurals name="theme_videos">
|
||||
<item quantity="one">Vídeos Tema</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="one">Vídeo Tema</item>
|
||||
<item quantity="many">Vídeos Tema</item>
|
||||
<item quantity="other">Vídeos Tema</item>
|
||||
</plurals>
|
||||
<plurals name="clips">
|
||||
<item quantity="one">Clipes</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="one">Clipe</item>
|
||||
<item quantity="many">Clipes</item>
|
||||
<item quantity="other">Clipes</item>
|
||||
</plurals>
|
||||
<plurals name="deleted_scenes">
|
||||
<item quantity="one">Cenas Deletadas</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="one">Cena Deletada</item>
|
||||
<item quantity="many">Cenas Deletadas</item>
|
||||
<item quantity="other">Cenas Deletadas</item>
|
||||
</plurals>
|
||||
<plurals name="interviews">
|
||||
<item quantity="one">Entrevistas</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="one">Entrevista</item>
|
||||
<item quantity="many">Entrevistas</item>
|
||||
<item quantity="other">Entrevistas</item>
|
||||
</plurals>
|
||||
<plurals name="scenes">
|
||||
<item quantity="one">Cenas</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="one">Cena</item>
|
||||
<item quantity="many">Cenas</item>
|
||||
<item quantity="other">Cenas</item>
|
||||
</plurals>
|
||||
<plurals name="samples">
|
||||
<item quantity="one">Amostras</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="one">Amostra</item>
|
||||
<item quantity="many">Amostras</item>
|
||||
<item quantity="other">Amostras</item>
|
||||
</plurals>
|
||||
<plurals name="shorts">
|
||||
<item quantity="one">Curtas</item>
|
||||
<item quantity="many"/>
|
||||
<item quantity="other"/>
|
||||
<item quantity="one">Curta</item>
|
||||
<item quantity="many">Curtas</item>
|
||||
<item quantity="other">Curtas</item>
|
||||
</plurals>
|
||||
<string name="favorite_items">Favoritar %s</string>
|
||||
<plurals name="downloads">
|
||||
|
|
@ -461,4 +461,124 @@
|
|||
<string name="height">Altura</string>
|
||||
<string name="apply_all_rows">Aplicar a todas as linhas</string>
|
||||
<string name="customize_home">Personalizar página inicial</string>
|
||||
<string name="skip_back_on_resume_preference">Pular para trás ao resumir a reprodução</string>
|
||||
<string name="skip_back_preference">Pular para trás</string>
|
||||
<string name="skip_forward_preference">Pular para frente</string>
|
||||
<string name="nal">NAL</string>
|
||||
<string name="avc">AVC</string>
|
||||
<string name="save_for_album">Salvar para álbum</string>
|
||||
<string name="home_rows">Linhas iniciais</string>
|
||||
<string name="load_from_server">Carregar a partir do perfil de usuário no servidor</string>
|
||||
<string name="save_to_server">Salvar para o perfil de usuário no servidor</string>
|
||||
<string name="load_from_web_client">Carregar a partir do cliente web</string>
|
||||
<string name="use_series">Usar imagem da série</string>
|
||||
<string name="add_row_for">Adicionar linha para %1$s</string>
|
||||
<string name="overwrite_server_settings">Sobrescrever configurações no servidor?</string>
|
||||
<string name="overwrite_local_settings">Sobrescrever configurações locais?</string>
|
||||
<string name="for_episodes">Para episódios</string>
|
||||
<string name="suggestions_for">Sugestões para %1$s</string>
|
||||
<string name="increase_all_cards_size">Aumentar tamanho para todos os cartões</string>
|
||||
<string name="decrease_all_cards_size">Diminuir tamanho para todos os cartões</string>
|
||||
<string name="use_thumb_images">Usar imagens de prévia</string>
|
||||
<string name="settings_saved">Configurações salvas</string>
|
||||
<string name="display_presets">Predefinições de exibição</string>
|
||||
<string name="display_presets_description">Predefinições embutidas para estilizar rapidamente todas as linhas</string>
|
||||
<string name="customize_home_summary">Escolher linhas e imagens na página inicial</string>
|
||||
<string name="start_after">Iniciar após</string>
|
||||
<string name="animate">Animar</string>
|
||||
<string name="max_age_rating">Classificação etária máxima</string>
|
||||
<string name="no_max">Sem máximo</string>
|
||||
<string name="for_all_ages">Para todas as idades</string>
|
||||
<string name="up_to_age">Até a idade %s</string>
|
||||
<string name="screensaver">Protetor de tela</string>
|
||||
<string name="screensaver_settings">Configurações do protetor de tela</string>
|
||||
<string name="start_screensaver">Iniciar o protetor de tela</string>
|
||||
<string name="duration">Duração</string>
|
||||
<string name="photos">Fotos</string>
|
||||
<string name="include_types">Incluir tipos</string>
|
||||
<string name="include_types_summary">Para quais tipos de itens mostrar imagens</string>
|
||||
<string name="content_scale_fit">Ajustar</string>
|
||||
<string name="content_scale_crop">Cortar</string>
|
||||
<string name="content_scale_fill">Preencher</string>
|
||||
<string name="content_scale_fill_width">Preencher largura</string>
|
||||
<string name="content_scale_fill_height">Preencher altura</string>
|
||||
<string name="display_preset_default">Padrão do Wholphin</string>
|
||||
<string name="display_preset_compact">Wholphin Compacto</string>
|
||||
<string name="display_preset_series_thumb">Prévia das imagens das séries</string>
|
||||
<string name="display_preset_episode_thumbnails">Prévias das imagens do episódio</string>
|
||||
<string name="volume_lowest">Mais baixo</string>
|
||||
<string name="volume_low">Baixo</string>
|
||||
<string name="volume_medium">Médio</string>
|
||||
<string name="volume_high">Alto</string>
|
||||
<string name="volume_full">Máximo</string>
|
||||
<string name="purple">Roxo</string>
|
||||
<string name="orange">Laranja</string>
|
||||
<string name="bold_blue">Azul intenso</string>
|
||||
<string name="black">Preto</string>
|
||||
<string name="skip_ignore">Ignorar</string>
|
||||
<string name="skip_automatically">Pular automaticamente</string>
|
||||
<string name="skip_ask">Perguntar se pular</string>
|
||||
<string name="ffmpeg_fallback">Apenas usar FFmpeg se não existir nenhum decodificador embarcado</string>
|
||||
<string name="ffmpeg_prefer">Preferir usar FFmpeg aos decodificadores embarcados</string>
|
||||
<string name="ffmpeg_never">Nunca usar decodificadores FFmpeg</string>
|
||||
<string name="next_up_playback_end">Ao fim da reprodução</string>
|
||||
<string name="next_up_outro">Durante os créditos/finalização</string>
|
||||
<string name="white">Branco</string>
|
||||
<string name="light_gray">Cinza claro</string>
|
||||
<string name="dark_gray">Cinza escuro</string>
|
||||
<string name="yellow">Amarelo</string>
|
||||
<string name="cyan">Ciano</string>
|
||||
<string name="magenta">Magenta</string>
|
||||
<string name="subtitle_edge_outline">Contorno</string>
|
||||
<string name="subtitle_edge_shadow">Sombra</string>
|
||||
<string name="background_style_wrap">Envolver</string>
|
||||
<string name="background_style_boxed">Encaixotar</string>
|
||||
<string name="prefer_mpv">Preferir MPV</string>
|
||||
<string name="player_backend_options_subtitles_prefer_mpv">Usar ExoPlayer para reprodução HDR</string>
|
||||
<string name="aspect_ratios_poster">Pôster (2:3)</string>
|
||||
<string name="aspect_ratios_16_9">16:9</string>
|
||||
<string name="aspect_ratios_4_3">4:3</string>
|
||||
<string name="aspect_ratios_square">Quadrado (1:1)</string>
|
||||
<string name="image_type_primary">Primária</string>
|
||||
<string name="image_type_thumb">Prévia</string>
|
||||
<string name="backdrop_style_dynamic">Imagem com cor dinâmica</string>
|
||||
<string name="backdrop_style_image">Apenas imagem</string>
|
||||
<string name="api_key">Chave de API</string>
|
||||
<string name="seerr_login">Autenticar no servidor Seerr</string>
|
||||
<string name="seerr_jellyfin_user">Usuário Jellyfin</string>
|
||||
<string name="seerr_local_user">Usuário local</string>
|
||||
<string name="no_subtitles_found">Nenhuma legenda remota encontrada</string>
|
||||
<string name="series_continueing">Presente</string>
|
||||
<string name="in_app_screensaver">Usar protetor de tela do aplicativo</string>
|
||||
<string name="delete_item">Tem certeza que quer apagar este item?</string>
|
||||
<string name="show_media_management">Mostrar opções de gerenciamento de mídia</string>
|
||||
<string name="actor">Ator</string>
|
||||
<string name="composer">Compositor</string>
|
||||
<string name="writer">Escritor</string>
|
||||
<string name="guest_star">Estrela convidada</string>
|
||||
<string name="producer">Produtor</string>
|
||||
<string name="conductor">Regente</string>
|
||||
<string name="lyricist">Letrista</string>
|
||||
<string name="arranger">Arranjador</string>
|
||||
<string name="engineer">Engenheiro</string>
|
||||
<string name="creator">Criador</string>
|
||||
<string name="artist">Artista</string>
|
||||
<string name="search_for">Buscar %s</string>
|
||||
<string name="mixer">Mixagem</string>
|
||||
<plurals name="featurettes">
|
||||
<item quantity="one">Bastidores</item>
|
||||
<item quantity="many"></item>
|
||||
<item quantity="other"></item>
|
||||
</plurals>
|
||||
<plurals name="minutes">
|
||||
<item quantity="one">%s minuto</item>
|
||||
<item quantity="many">%s minutos</item>
|
||||
<item quantity="other">%s minutos</item>
|
||||
</plurals>
|
||||
<string name="enter_url_api_key">Insira a URL e a chave de API</string>
|
||||
<string name="search_and_download_subtitles">Procurar e baixar legendas</string>
|
||||
<string name="download_and_update">Baixar e atualizar</string>
|
||||
<string name="search_and_download">Procurar e baixar</string>
|
||||
<string name="combine_continue_next">Combinar \"Continue Assistindo\" e \"A Seguir\"</string>
|
||||
<string name="cast_and_crew">Elenco e equipe</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -566,4 +566,34 @@
|
|||
<string name="mixer">Técnico de mixagem</string>
|
||||
<string name="creator">Criador</string>
|
||||
<string name="artist">Artista</string>
|
||||
<string name="delete_item">Tem a certeza de que deseja eliminar este elemento?</string>
|
||||
<string name="show_media_management">Mostrar opções de gestão de multimédia</string>
|
||||
<string name="search_for">Pesquisar %s</string>
|
||||
<string name="select_all">Selecionar tudo</string>
|
||||
<string name="albums">Álbuns</string>
|
||||
<string name="artists">Artistas</string>
|
||||
<string name="songs">Músicas</string>
|
||||
<string name="go_to_artist">Ir para o artista</string>
|
||||
<string name="now_playing">A reproduzir</string>
|
||||
<string name="instant_mix">Mistura instantânea</string>
|
||||
<string name="go_to_album">Ir para o álbum</string>
|
||||
<string name="add_to_queue">Adicionar à fila</string>
|
||||
<string name="album_artist">Artista do álbum</string>
|
||||
<string name="album">Álbum</string>
|
||||
<string name="popular_songs">Mais ouvidas</string>
|
||||
<string name="play_next">Reproduzir a seguir</string>
|
||||
<string name="music_videos">Videoclips</string>
|
||||
<string name="lyrics">Letras</string>
|
||||
<string name="hide_lyrics">Esconder letras</string>
|
||||
<string name="show_lyrics">Mostrar letras</string>
|
||||
<string name="song_has_lyrics">Música contêm letras</string>
|
||||
<string name="remove_from_queue">Remover da fila</string>
|
||||
<string name="show_album_cover">Mostrar capa do álbum</string>
|
||||
<string name="show_visualizer">Mostrar visualizador</string>
|
||||
<string name="show_backdrop">Mostrar imagem de fundo</string>
|
||||
<string name="play_as_type">Reproduzir como?</string>
|
||||
<string name="visualizer_rationale">Para visualizar o áudio que está a ser reproduzido, é necessária autorização para gravar áudio.</string>
|
||||
<string name="continue_string">Continuar</string>
|
||||
<string name="separate_types">Separar por tipos</string>
|
||||
<string name="prefer_logos">Prefiro mostrar logótipos nos títulos</string>
|
||||
</resources>
|
||||
|
|
|
|||
309
app/src/main/res/values-ro/strings.xml
Normal file
309
app/src/main/res/values-ro/strings.xml
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="about">Despre</string>
|
||||
<string name="add_favorite">Favorite</string>
|
||||
<string name="add_server">Adaugă server</string>
|
||||
<string name="add_user">Adaugă utilizator</string>
|
||||
<string name="audio">Audio</string>
|
||||
<string name="birthplace">Locul nașterii</string>
|
||||
<string name="born">Născut</string>
|
||||
<string name="cancel_recording">Anulează înregistrarea</string>
|
||||
<string name="cancel">Anulează</string>
|
||||
<string name="chapters">Capitole</string>
|
||||
<string name="choose_stream">Alege %1$s</string>
|
||||
<string name="collection">Colecție</string>
|
||||
<string name="collections">Colecții</string>
|
||||
<string name="downloading">Se descarcă…</string>
|
||||
<string name="directed_by">Regizat de %1$s</string>
|
||||
<string name="clear_image_cache">Golește memoria cache a imaginilor</string>
|
||||
<string name="confirm">Confirmă</string>
|
||||
<string name="director">Regizor</string>
|
||||
<string name="favorites">Favorite</string>
|
||||
<string name="ends_at">Se încheie la %1$s</string>
|
||||
<string name="enter_server_url">Introdu adresa IP sau URL-ul</string>
|
||||
<string name="enter_server_address">Introdu adresa serverului</string>
|
||||
<string name="episodes">Episoade</string>
|
||||
<string name="error_loading_collection">Eroare la încărcarea colecției %1$s</string>
|
||||
<string name="external_track">Extern</string>
|
||||
<string name="file_size">Dimensiune</string>
|
||||
<string name="enabled">Activat</string>
|
||||
<string name="delete">Șterge</string>
|
||||
<string name="continue_watching">Continuă vizionarea</string>
|
||||
<string name="decimal_seconds">%.2f secunde</string>
|
||||
<string name="disabled">Dezactivat</string>
|
||||
<string name="genres">Genuri</string>
|
||||
<string name="intro">Introducere</string>
|
||||
<string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string>
|
||||
<string name="name">Nume</string>
|
||||
<string name="next_up">În continuare</string>
|
||||
<string name="no_results">Nu există rezultate</string>
|
||||
<string name="no_servers_found">Nu s-au găsit servere</string>
|
||||
<string name="no_scheduled_recordings">Nu sunt înregistrări programate</string>
|
||||
<string name="no_update_available">Nu este disponibilă nicio actualizare</string>
|
||||
<plurals name="movies">
|
||||
<item quantity="one">Filme</item>
|
||||
<item quantity="few"></item>
|
||||
<item quantity="other"></item>
|
||||
</plurals>
|
||||
<string name="library">Librărie</string>
|
||||
<string name="go_to">Mergi la</string>
|
||||
<string name="go_to_series">Mergi la seria</string>
|
||||
<string name="loading">Se încarcă…</string>
|
||||
<string name="mark_entire_series_as_played">Marchez întreaga serie ca vizionată?</string>
|
||||
<string name="mark_entire_series_as_unplayed">Marchez întreaga serie ca nevizionată?</string>
|
||||
<string name="mark_unwatched">Marchează ca nevizionat</string>
|
||||
<string name="mark_watched">Marchează ca vizionat</string>
|
||||
<plurals name="people">
|
||||
<item quantity="one">Persoane</item>
|
||||
<item quantity="few"></item>
|
||||
<item quantity="other"></item>
|
||||
</plurals>
|
||||
<string name="play_count">Număr de redări</string>
|
||||
<string name="more">Mai mult</string>
|
||||
<string name="home">Acasă</string>
|
||||
<string name="no_data">Nu există date</string>
|
||||
<string name="outro">Încheiere</string>
|
||||
<string name="path">Cale</string>
|
||||
<string name="more_like_this">Mai multe articole similare</string>
|
||||
<string name="hide">Ascunde</string>
|
||||
<string name="language">Limbă</string>
|
||||
<string name="resolution">Rezoluție</string>
|
||||
<string name="title">Titlu</string>
|
||||
<string name="columns">Coloane</string>
|
||||
<string name="confirm_pin">Confirmă PIN</string>
|
||||
<string name="dolby_vision">Dolby Vision</string>
|
||||
<string name="dolby_atmos">Dolby Atmos</string>
|
||||
<string name="enter_pin">Introdu PIN</string>
|
||||
<string name="sign_in_auto">Conectare automată</string>
|
||||
<string name="pin_too_short">PIN-ul trebuie sa aibă 4 sau mai multe caractere</string>
|
||||
<string name="discard_change">Anulezi modificările?</string>
|
||||
<string name="year">An</string>
|
||||
<string name="decade">Decadă</string>
|
||||
<string name="filter">Filtru</string>
|
||||
<string name="played">Redat</string>
|
||||
<string name="mpv_options">Opțiuni MPV</string>
|
||||
<string name="exoplayer_options">Opțiuni ExoPlayer</string>
|
||||
<string name="mpv_conf">Modifică mpv.conf</string>
|
||||
<string name="spacing">Spațiere</string>
|
||||
<string name="aspect_ratio">Rată de aspect</string>
|
||||
<string name="show_details">Înregistrare detaliată</string>
|
||||
<string name="image_type">Tip imagine</string>
|
||||
<string name="cast_and_crew"><![CDATA[Distribuție și echipă]]></string>
|
||||
<string name="guest_stars">Vedete invitate</string>
|
||||
<string name="level">Nivel</string>
|
||||
<string name="channels">Canale</string>
|
||||
<string name="yes">Da</string>
|
||||
<string name="no">Nu</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="show_titles">Arată titlurile</string>
|
||||
<string name="live_tv_repeat">Repetă</string>
|
||||
<string name="favorite_channels_at_beginning">Afișează mai întâi canalele favorite</string>
|
||||
<string name="sort_channels_recently_watched">Sortează canalele după vizionarea recentă</string>
|
||||
<string name="subtitle_delay">Decalare subitrare</string>
|
||||
<string name="discover">Descoperă</string>
|
||||
<string name="play_trailer">Redă trailer</string>
|
||||
<string name="no_trailers">Fără trailere</string>
|
||||
<string name="seerr_integration">Integrare Seerr</string>
|
||||
<string name="remove_seerr_server">Elimină serverul Seerr</string>
|
||||
<string name="seerr_server_added">Serverul Seer adăugat</string>
|
||||
<string name="quick_connect">Conectare rapidă</string>
|
||||
<string name="quick_connect_summary">Autorizează un alt dispozitiv să se conecteze la contul tău</string>
|
||||
<string name="quick_connect_code">Introdu codul de conectare rapidă</string>
|
||||
<string name="quick_connect_code_error">Codul trebuie să aibă 6 cifre</string>
|
||||
<string name="quick_connect_success">Dispozitiv autorizat cu success</string>
|
||||
<string name="password">Parolă</string>
|
||||
<string name="username">Nume utilizator</string>
|
||||
<string name="url">Adresă URL</string>
|
||||
<string name="trending">În tendințe</string>
|
||||
<string name="upcoming_movies">Filme viitoare</string>
|
||||
<string name="upcoming_tv">Seriale viitoare</string>
|
||||
<string name="request_4k">Solicită în format 4K</string>
|
||||
<string name="brightness">Luminozitate</string>
|
||||
<string name="contrast">Contrast</string>
|
||||
<string name="saturation">Saturație</string>
|
||||
<string name="hue">Nuanță</string>
|
||||
<string name="red">Roșu</string>
|
||||
<string name="green">Verde</string>
|
||||
<string name="blue">Albastru</string>
|
||||
<string name="blur">Estompare</string>
|
||||
<string name="save_for_album">Salvează pentru album</string>
|
||||
<string name="albums">Albume</string>
|
||||
<string name="artists">Artiști</string>
|
||||
<string name="songs">Cântece</string>
|
||||
<string name="go_to_artist">Mergi la artist</string>
|
||||
<string name="now_playing">Se redă acum</string>
|
||||
<string name="instant_mix">Mix instant</string>
|
||||
<string name="go_to_album">Mergi la album</string>
|
||||
<string name="add_to_queue">Adaugă în coadă</string>
|
||||
<string name="add_row">Adaugă rând</string>
|
||||
<string name="genres_in">Genuri în %1$s</string>
|
||||
<string name="recently_released_in">Lansate recent în %1$s</string>
|
||||
<string name="height">Înălțime</string>
|
||||
<string name="apply_all_rows">Aplică la toate rândurile</string>
|
||||
<string name="customize_home">Personalizează pagina de pornire</string>
|
||||
<string name="for_episodes">Pentru episoade</string>
|
||||
<string name="suggestions_for">Sugestii pentru %1$s</string>
|
||||
<string name="increase_all_cards_size">Mărește dimensiunea tuturor cardurilor</string>
|
||||
<string name="decrease_all_cards_size">Micșorează dimensiunea tuturor cardurilor</string>
|
||||
<string name="settings_saved">Setări salvate</string>
|
||||
<string name="duration">Durată</string>
|
||||
<string name="photos">Fotografii</string>
|
||||
<string name="volume_lowest">Cel mai scăzut</string>
|
||||
<string name="volume_low">Scăzut</string>
|
||||
<string name="volume_medium">Mediu</string>
|
||||
<string name="volume_high">Ridicat</string>
|
||||
<string name="volume_full">Maxim</string>
|
||||
<string name="purple">Violet</string>
|
||||
<string name="orange">Portocaliu</string>
|
||||
<string name="white">Alb</string>
|
||||
<string name="light_gray">Gri deschis</string>
|
||||
<string name="dark_gray">Gri închis</string>
|
||||
<string name="yellow">Galben</string>
|
||||
<string name="cyan">Cian</string>
|
||||
<string name="magenta">Magenta</string>
|
||||
<string name="subtitle_edge_outline">Contur</string>
|
||||
<string name="subtitle_edge_shadow">Umbră</string>
|
||||
<string name="prefer_mpv">Preferă MPV</string>
|
||||
<string name="player_backend_options_subtitles_prefer_mpv">Folosește ExoPlayer pentru redare HDR</string>
|
||||
<string name="aspect_ratios_square">Pătrat (1:1)</string>
|
||||
<string name="api_key">Cheie API</string>
|
||||
<string name="seerr_login">Conectează-te la serverul Seerr</string>
|
||||
<string name="discovered_servers">Servere descoperite</string>
|
||||
<string name="download_and_update"><![CDATA[Descarcă & Utilizează]]></string>
|
||||
<string name="commercial">Reclamă</string>
|
||||
<string name="community_rating">Evaluarea comunității</string>
|
||||
<string name="compose_error_message_title">A apărut o eroare! Apasă butonul pentru a trimite jurnalele către serverul tău.</string>
|
||||
<string name="default_track">Implicit</string>
|
||||
<string name="died">Decedat</string>
|
||||
<string name="active_recordings">Înregistrări active</string>
|
||||
<string name="critic_rating">Evaluarea criticilor</string>
|
||||
<string name="hide_controller_timeout">Ascunde comenzile de redare</string>
|
||||
<string name="live_tv">TV în direct</string>
|
||||
<string name="play">Redă</string>
|
||||
<string name="recap">Recapitulare</string>
|
||||
<string name="recently_added">Adăugat recent</string>
|
||||
<string name="recently_recorded">Înregistrat recent</string>
|
||||
<string name="recently_released">Lansat recent</string>
|
||||
<string name="recommended">Recomandat</string>
|
||||
<string name="playlists">Liste de redare</string>
|
||||
<string name="preview">Previzualizare</string>
|
||||
<string name="profile_specific_settings">Setări profil utilizator</string>
|
||||
<string name="queue">Coadă</string>
|
||||
<string name="recently_added_in">Adăugat recent în %1$s</string>
|
||||
<string name="search">Caută</string>
|
||||
<string name="searching">Se caută…</string>
|
||||
<string name="voice_search">Căutare vocală</string>
|
||||
<string name="processing">Se procesează</string>
|
||||
<string name="select_server">Alege server</string>
|
||||
<string name="select_user">Alege utilizator</string>
|
||||
<string name="skip">Sări peste</string>
|
||||
<string name="retry">Reîncearcă</string>
|
||||
<string name="show">Arată</string>
|
||||
<string name="studios">Studiouri</string>
|
||||
<string name="submit">Trimite</string>
|
||||
<string name="subtitle_download_too_long">Descărcarea durează mult; s-ar putea să fie necesar să repornești redarea</string>
|
||||
<string name="subtitle">Subtitrare</string>
|
||||
<string name="subtitles">Subtitrări</string>
|
||||
<string name="suggestions">Sugestii</string>
|
||||
<string name="switch_servers">Schimbă serverele</string>
|
||||
<string name="switch_user">Schimbă</string>
|
||||
<string name="tv_season">Sezon</string>
|
||||
<string name="tv_seasons">Sezoane</string>
|
||||
<plurals name="tv_shows">
|
||||
<item quantity="one">Seriale TV</item>
|
||||
<item quantity="few"></item>
|
||||
<item quantity="other"></item>
|
||||
</plurals>
|
||||
<string name="ui_interface">Interfață</string>
|
||||
<string name="unknown">Necunoscut</string>
|
||||
<string name="updates">Actualizări</string>
|
||||
<string name="version">Versiune</string>
|
||||
<string name="show_clock">Arată ceasul</string>
|
||||
<string name="years_old">%1$d ani vechime</string>
|
||||
<string name="tv_guide">Ghid</string>
|
||||
<string name="one_click_pause">Pauză cu un click</string>
|
||||
<plurals name="interviews">
|
||||
<item quantity="one">Interviuri</item>
|
||||
<item quantity="few"></item>
|
||||
<item quantity="other"></item>
|
||||
</plurals>
|
||||
<string name="background">Fundal</string>
|
||||
<plurals name="downloads">
|
||||
<item quantity="one">%s descărcare</item>
|
||||
<item quantity="few">%s descărcări</item>
|
||||
<item quantity="other">%s descărcări</item>
|
||||
</plurals>
|
||||
<plurals name="hours">
|
||||
<item quantity="one">%d oră</item>
|
||||
<item quantity="few">%d ore</item>
|
||||
<item quantity="other">%d ore</item>
|
||||
</plurals>
|
||||
<plurals name="days">
|
||||
<item quantity="one">%s zi</item>
|
||||
<item quantity="few">%s zile</item>
|
||||
<item quantity="other">%s zile</item>
|
||||
</plurals>
|
||||
<plurals name="items">
|
||||
<item quantity="one">%d articol</item>
|
||||
<item quantity="few">%d articole</item>
|
||||
<item quantity="other">%d articole</item>
|
||||
</plurals>
|
||||
<plurals name="seconds">
|
||||
<item quantity="one">%d secundă</item>
|
||||
<item quantity="few">%d secunde</item>
|
||||
<item quantity="other">%d secunde</item>
|
||||
</plurals>
|
||||
<plurals name="minutes">
|
||||
<item quantity="one">%s minut</item>
|
||||
<item quantity="few">%s minute</item>
|
||||
<item quantity="other">%s minute</item>
|
||||
</plurals>
|
||||
<string name="check_for_updates">Verifică dacă sunt actualizări</string>
|
||||
<string name="install_update">Instalează actualizarea</string>
|
||||
<string name="installed_version">Versiune instalată</string>
|
||||
<string name="settings">Setări</string>
|
||||
<string name="font_size">Dimensiune font</string>
|
||||
<string name="font_color">Culoare font</string>
|
||||
<string name="font_opacity">Opacitate font</string>
|
||||
<string name="edge_style">Stil margine</string>
|
||||
<string name="background_color">Culoare fundal</string>
|
||||
<string name="reset">Resetare</string>
|
||||
<string name="update_available">Actualizare disponibilă</string>
|
||||
<string name="update_url_summary">URL folosit pentru a căuta actualizări</string>
|
||||
<string name="update_url">URL actualizare</string>
|
||||
<string name="send_crash_reports">Trimite rapoarte de eroare</string>
|
||||
<string name="subtitle_style">Stil subtitrare</string>
|
||||
<string name="background_style">Stil fundal</string>
|
||||
<string name="skip_segment_intro">Sări peste introducere</string>
|
||||
<string name="skip_segment_commercial">Sări peste reclame</string>
|
||||
<string name="skip_segment_preview">Sări peste previzualizare</string>
|
||||
<string name="skip_segment_recap">Sări peste recapitulare</string>
|
||||
<string name="incorrect">Incorect</string>
|
||||
<string name="username_or_password">Folosește utilizator/parolă</string>
|
||||
<string name="request">Solicitare</string>
|
||||
<string name="pending">În așteptare</string>
|
||||
<string name="upgrade_mpv_toast">MPV este acum playerul implicit, cu excepția conținutului HDR.\nPoți modifica aceasta din setări.</string>
|
||||
<string name="hdr_subtitle_style">Stilul subtitrărilor HDR</string>
|
||||
<string name="rotate_right">Rotește la dreapta</string>
|
||||
<string name="for_all_ages">Pentru toate vârstele</string>
|
||||
<string name="up_to_age">Până la vârsta de %s</string>
|
||||
<string name="screensaver">Economizor de ecran</string>
|
||||
<string name="screensaver_settings">Setări economizor de ecran</string>
|
||||
<string name="skip_automatically">Sări automat</string>
|
||||
<string name="skip_ask">Cere să sari peste</string>
|
||||
<string name="seerr_jellyfin_user">Utilizator Jellyfin</string>
|
||||
<string name="seerr_local_user">Utilizator local</string>
|
||||
<string name="search_and_download_subtitles"><![CDATA[Caută & descarcă subtitrări]]></string>
|
||||
<string name="composer">Compozitor</string>
|
||||
<string name="writer">Scriitor</string>
|
||||
<string name="lyrics">Versuri</string>
|
||||
<string name="hide_lyrics">Ascunde versurile</string>
|
||||
<string name="show_lyrics">Afișează versurile</string>
|
||||
<string name="song_has_lyrics">Cântecul are versuri</string>
|
||||
<string name="select_all">Selectează tot</string>
|
||||
<string name="play_as_type">Redă ca?</string>
|
||||
</resources>
|
||||
|
|
@ -393,4 +393,28 @@
|
|||
<string name="ref_frames">Опорные кадры</string>
|
||||
<string name="yes">Да</string>
|
||||
<string name="no">Нет</string>
|
||||
<plurals name="days">
|
||||
<item quantity="one">%s день</item>
|
||||
<item quantity="few">%s дня</item>
|
||||
<item quantity="many">%s дней</item>
|
||||
<item quantity="other">%s дней</item>
|
||||
</plurals>
|
||||
<plurals name="minutes">
|
||||
<item quantity="one">%s минута</item>
|
||||
<item quantity="few">%s минуты</item>
|
||||
<item quantity="many">%s минут</item>
|
||||
<item quantity="other">%s минут</item>
|
||||
</plurals>
|
||||
<string name="channels">Каналы</string>
|
||||
<string name="sample_rate">Частота дискретизации</string>
|
||||
<string name="contrast">Контраст</string>
|
||||
<string name="saturation">Насыщенность</string>
|
||||
<string name="hue">Оттенок</string>
|
||||
<string name="red">Красный</string>
|
||||
<string name="green">Зелёный</string>
|
||||
<string name="blue">Синий</string>
|
||||
<string name="brightness">Яркость</string>
|
||||
<string name="seerr_integration">Интеграция с Seerr</string>
|
||||
<string name="search_for">Найти %s</string>
|
||||
<string name="select_all">Выбрать всё</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="about">O Wholphine</string>
|
||||
<string name="about">O aplikácii</string>
|
||||
<string name="active_recordings">Aktívne nahrávky</string>
|
||||
<string name="add_favorite">Obľúbené</string>
|
||||
<string name="add_server">Pridať server</string>
|
||||
|
|
@ -182,7 +182,7 @@
|
|||
<string name="background">Pozadie</string>
|
||||
<string name="success">Úspech</string>
|
||||
<string name="official_rating">Rodičovské hodnotenie</string>
|
||||
<string name="runtime_sort">Trvanie</string>
|
||||
<string name="runtime_sort">Dĺžka</string>
|
||||
<string name="extras">Bonusový materiál</string>
|
||||
<plurals name="other_extras">
|
||||
<item quantity="one">Ostatné</item>
|
||||
|
|
@ -309,4 +309,289 @@
|
|||
<string name="send_crash_reports_summary">Pokus o odoslanie na posledný pripojený server</string>
|
||||
<string name="send_crash_reports">Odoslať správy o zlyhaní</string>
|
||||
<string name="settings">Nastavenia</string>
|
||||
<plurals name="minutes">
|
||||
<item quantity="one">%s minúta</item>
|
||||
<item quantity="few">%s minúty</item>
|
||||
<item quantity="many">%s minút</item>
|
||||
<item quantity="other">%s minút</item>
|
||||
</plurals>
|
||||
<string name="auto_play_next_delay">Oneskorenie pred prehrávaním nasledujúceho</string>
|
||||
<string name="auto_play_next">Automatické prehrávanie nasledujúceho</string>
|
||||
<string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string>
|
||||
<string name="playback_overrides">Prepísanie prehrávania</string>
|
||||
<string name="rewatch_next_up">Povoliť opätovné prehrávanie v nasledujúcich</string>
|
||||
<string name="skip_back_on_resume_preference">Pri obnovení prehrávania preskočiť späť</string>
|
||||
<string name="skip_back_preference">Preskočiť späť</string>
|
||||
<string name="skip_commercials_behavior">Správanie preskočenia reklamy</string>
|
||||
<string name="skip_forward_preference">Preskočiť dopredu</string>
|
||||
<string name="skip_intro_behavior">Správanie preskočenia intra</string>
|
||||
<string name="skip_outro_behavior">Správanie preskočenia outra</string>
|
||||
<string name="skip_previews_behavior">Správanie preskočenia náhľadov</string>
|
||||
<string name="skip_recap_behavior">Správanie preskočenia rekapitulácie</string>
|
||||
<string name="update_available">Aktualizácia k dispozícii</string>
|
||||
<string name="update_url_summary">URL používaná na kontrolu aktualizácií aplikácie</string>
|
||||
<string name="update_url">Aktualizovať URL</string>
|
||||
<string name="font_size">Veľkosť písma</string>
|
||||
<string name="font_color">Farba písma</string>
|
||||
<string name="font_opacity">Priehľadnosť písma</string>
|
||||
<string name="edge_style">Štýl okraja</string>
|
||||
<string name="edge_color">Farba okraja</string>
|
||||
<string name="background_opacity">Prehľadnosť pozadia</string>
|
||||
<string name="background_style">Štýl pozadia</string>
|
||||
<string name="subtitle_style">Štýl titulkov</string>
|
||||
<string name="background_color">Farba pozadia</string>
|
||||
<string name="reset">Reset</string>
|
||||
<string name="bold_font">Tučné písmo</string>
|
||||
<string name="player_backend">Backend prehrávania</string>
|
||||
<string name="mpv_hardware_decoding">MPV: Používa hardvérové dekódovanie</string>
|
||||
<string name="disable_if_crash">Zakážte, ak dochádza k pádom</string>
|
||||
<string name="mpv_options">Možnosti MPV</string>
|
||||
<string name="exoplayer_options">Možnosti ExoPlayer</string>
|
||||
<string name="skip_segment_unknown">Preskočiť segment</string>
|
||||
<string name="skip_segment_commercial">Preskočiť reklamy</string>
|
||||
<string name="skip_segment_preview">Preskočiť náhľad</string>
|
||||
<string name="skip_segment_recap">Preskočiť rekapituláciu</string>
|
||||
<string name="skip_segment_outro">Preskočiť outro</string>
|
||||
<string name="skip_segment_intro">Preskočiť intro</string>
|
||||
<string name="played">Prehrané</string>
|
||||
<string name="filter">Filter</string>
|
||||
<string name="year">Rok</string>
|
||||
<string name="decade">Desaťročie</string>
|
||||
<string name="remove">Odstrániť</string>
|
||||
<string name="dolby_vision">Dolby Vision</string>
|
||||
<string name="dolby_atmos">Dolby Atmos</string>
|
||||
<string name="mpv_use_gpu_next">MPV: Použiť gpu-next</string>
|
||||
<string name="mpv_conf">Upraviť mpv.conf</string>
|
||||
<string name="discard_change">Zrušiť zmeny?</string>
|
||||
<string name="subtitle_margin">Okraj</string>
|
||||
<string name="verbose_logging">Podrobné logovanie</string>
|
||||
<string name="enter_pin">Zadajte PIN</string>
|
||||
<string name="sign_in_auto">Automatické prihlásenie</string>
|
||||
<string name="use_server_credentials">Prihlásenie cez server</string>
|
||||
<string name="press_enter_to_confirm">Stlačte stredové tlačidlo na potvrdenie</string>
|
||||
<string name="require_pin_code">Vyžadovať PIN pre profil</string>
|
||||
<string name="confirm_pin">Potvrdiť PIN</string>
|
||||
<string name="incorrect">Nesprávny</string>
|
||||
<string name="pin_too_short">PIN musí mať minimálne 4 znaky</string>
|
||||
<string name="will_remove_pin">Odstráni PIN</string>
|
||||
<string name="image_cache_size">Veľkosť pamäte obrázkov (MB)</string>
|
||||
<string name="view_options">Možnosti zobrazenia</string>
|
||||
<string name="columns">Stĺpce</string>
|
||||
<string name="spacing">Rozostup</string>
|
||||
<string name="aspect_ratio">Pomer strán</string>
|
||||
<string name="show_details">Zobraziť podrobnosti</string>
|
||||
<string name="image_type">Typ obrázku</string>
|
||||
<string name="cast_and_crew"><![CDATA[Obsadenie a štáb]]></string>
|
||||
<string name="guest_stars">Hosťujúce hviezdy</string>
|
||||
<string name="edge_size">Veľkosť okraja</string>
|
||||
<string name="refresh_rate_switching">Prepínanie obnovovacej frekvencie</string>
|
||||
<string name="automatic">Automaticky</string>
|
||||
<string name="username_or_password">Použite používateľské meno/heslo</string>
|
||||
<string name="login">Prihlásenie</string>
|
||||
<string name="general">Všeobecné</string>
|
||||
<string name="container">Kontajner</string>
|
||||
<string name="title">Titul</string>
|
||||
<string name="codec">Kodek</string>
|
||||
<string name="profile">Profil</string>
|
||||
<string name="level">Úroveň</string>
|
||||
<string name="resolution">Rozlíšenie</string>
|
||||
<string name="anamorphic">Anamorfný</string>
|
||||
<string name="interlaced">Prekladané</string>
|
||||
<string name="framerate">Snímková frekvencia</string>
|
||||
<string name="bit_depth">Bitová hĺbka</string>
|
||||
<string name="video_range">Rozsah videa</string>
|
||||
<string name="video_range_type">Typ rozsahu videa</string>
|
||||
<string name="color_space">Farebný priestor</string>
|
||||
<string name="color_transfer">Prenos farieb</string>
|
||||
<string name="color_primaries">Základné farby</string>
|
||||
<string name="pixel_format">Formát pixelov</string>
|
||||
<string name="ref_frames">Referenčné snímky</string>
|
||||
<string name="nal">NAL</string>
|
||||
<string name="language">Jazyk</string>
|
||||
<string name="layout">Rozloženie</string>
|
||||
<string name="channels">Kanály</string>
|
||||
<string name="sample_rate">Vzorkovacia frekvencia</string>
|
||||
<string name="avc">AVC</string>
|
||||
<string name="yes">Áno</string>
|
||||
<string name="no">Nie</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">Zobraziť názvy</string>
|
||||
<string name="live_tv_repeat">Opakovať</string>
|
||||
<string name="favorite_channels_at_beginning">Zobraziť obľúbené kanály ako prvé</string>
|
||||
<string name="sort_channels_recently_watched">Zoradiť kanály podľa nedávno pozretých</string>
|
||||
<string name="color_code_programs">Farebné rozlíšenie programov</string>
|
||||
<string name="subtitle_delay">Oneskorenie titulkov</string>
|
||||
<string name="backdrop_display">Štýl backdropu</string>
|
||||
<string name="resolution_switching">Prepínanie rozlíšenia</string>
|
||||
<string name="local">Lokálne</string>
|
||||
<string name="play_trailer">Prehrať trailer</string>
|
||||
<string name="no_trailers">Žiadne trailery</string>
|
||||
<string name="clear_track_choices">Vyčistiť výber stôp</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">Priame prehrávanie Dolby Vision Profile 7</string>
|
||||
<string name="force_dovi_profile_7_summary">Ignorovať kontrolu kompatibility zariadenia</string>
|
||||
<string name="request">Žiadosť</string>
|
||||
<string name="pending">Čaká</string>
|
||||
<string name="seerr_integration">Integrácia Seerr</string>
|
||||
<string name="remove_seerr_server">Odstrániť Seerr server</string>
|
||||
<string name="seerr_server_added">Seerr server pridaný</string>
|
||||
<string name="quick_connect">Rýchle pripojenie</string>
|
||||
<string name="writer">Scenár</string>
|
||||
<string name="guest_star">Hosťujúca hviezda</string>
|
||||
<string name="producer">Producent</string>
|
||||
<string name="conductor">Dirigent</string>
|
||||
<string name="lyricist">Textár</string>
|
||||
<string name="arranger">Aranžér</string>
|
||||
<string name="engineer">Inžinier</string>
|
||||
<string name="mixer">Zvukový mixér</string>
|
||||
<string name="creator">Tvorca</string>
|
||||
<string name="artist">Umelec</string>
|
||||
<string name="search_for">Hľadať %s</string>
|
||||
<string name="quick_connect_code">Zadajte kód Rýchleho pripojenia</string>
|
||||
<string name="quick_connect_code_error">Kód musí mať 6 číslic</string>
|
||||
<string name="quick_connect_success">Zariadenie bolo úspešne autorizované</string>
|
||||
<string name="password">Heslo</string>
|
||||
<string name="username">Používateľské meno</string>
|
||||
<string name="url">URL</string>
|
||||
<string name="trending">Trendujúce</string>
|
||||
<string name="upcoming_movies">Nadchádzajúce filmy</string>
|
||||
<string name="upcoming_tv">Nadchádzajúce seriály</string>
|
||||
<string name="request_4k">Vyžiadať v 4K</string>
|
||||
<string name="software_decoding_av1">Softvérové dekódovanie AV1</string>
|
||||
<string name="upgrade_mpv_toast">MPV je teraz predvolený prehrávač okrem HDR.\nToto môžete zmeniť v nastaveniach.</string>
|
||||
<string name="hdr_subtitle_style">Štýl HDR titulkov</string>
|
||||
<string name="image_subtitle_opacity">Priehľadnosť obrázkových titulkov</string>
|
||||
<string name="brightness">Jas</string>
|
||||
<string name="contrast">Kontrast</string>
|
||||
<string name="saturation">Sýtosť</string>
|
||||
<string name="hue">Odtieň</string>
|
||||
<string name="red">Červená</string>
|
||||
<string name="green">Zelená</string>
|
||||
<string name="blue">Modrá</string>
|
||||
<string name="blur">Rozmazanie</string>
|
||||
<string name="save_for_album">Uložiť do albumu</string>
|
||||
<string name="play_slideshow">Prehrať prezentáciu</string>
|
||||
<string name="stop_slideshow">Zastaviť prezentáciu</string>
|
||||
<string name="slideshow_at_beginning">Na začiatok</string>
|
||||
<string name="no_more_images">Žiadne ďalšie fotky</string>
|
||||
<string name="rotate_left">Otočiť doľava</string>
|
||||
<string name="rotate_right">Otočiť doprava</string>
|
||||
<string name="zoom_in">Zväčšiť</string>
|
||||
<string name="zoom_out">Zmenšiť</string>
|
||||
<string name="slideshow_duration">Trvanie prezentácie</string>
|
||||
<string name="play_videos_during_slideshow">Prehrávať videá počas prezentácie</string>
|
||||
<string name="no_limit">Bez obmedzenia</string>
|
||||
<string name="dts_hd_ma">DTS:HD MA</string>
|
||||
<string name="overwrite_server_settings">Prepísať nastavenia na serveri?</string>
|
||||
<string name="overwrite_local_settings">Prepísať lokálne nastavenia?</string>
|
||||
<string name="for_episodes">Pre epizódy</string>
|
||||
<string name="suggestions_for">Návrhy pre %1$s</string>
|
||||
<string name="increase_all_cards_size">Zväčšiť veľkosť všetkých kariet</string>
|
||||
<string name="decrease_all_cards_size">Zmenšiť veľkosť všetkých kariet</string>
|
||||
<string name="use_thumb_images">Použiť náhľadové obrázky</string>
|
||||
<string name="settings_saved">Nastavenia uložené</string>
|
||||
<string name="display_presets">Predvoľby displeja</string>
|
||||
<string name="display_presets_description">Vstavané predvoľby na rýchlu úpravu všetkých riadkov</string>
|
||||
<string name="customize_home_summary">Vyberte riadky a obrázky na domovskej stránke</string>
|
||||
<string name="start_after">Začať po</string>
|
||||
<string name="animate">Animovať</string>
|
||||
<string name="max_age_rating">Maximálne vekové hodnotenie</string>
|
||||
<string name="no_max">Bez vekového obmedzenia</string>
|
||||
<string name="for_all_ages">Pre všetky vekové kategórie</string>
|
||||
<string name="up_to_age">Do veku %s</string>
|
||||
<string name="screensaver">Šetrič obrazovky</string>
|
||||
<string name="screensaver_settings">Nastavenia šetriča obrazovky</string>
|
||||
<string name="start_screensaver">Spustiť šetrič obrazovky</string>
|
||||
<string name="duration">Trvanie</string>
|
||||
<string name="photos">Fotografie</string>
|
||||
<string name="include_types">Zahrnúť typy</string>
|
||||
<string name="include_types_summary">Pre ktoré typy položiek zobrazovať obrázky</string>
|
||||
<string name="content_scale_fit">Prispôsobiť</string>
|
||||
<string name="content_scale_crop">Orezať</string>
|
||||
<string name="content_scale_fill">Vyplniť</string>
|
||||
<string name="content_scale_fill_width">Vyplniť na šírku</string>
|
||||
<string name="content_scale_fill_height">Vyplniť na výšku</string>
|
||||
<string name="display_preset_default">Wholphin predvolené</string>
|
||||
<string name="display_preset_compact">Wholphin kompaktné</string>
|
||||
<string name="display_preset_series_thumb">Náhľadové obrázky seriálov</string>
|
||||
<string name="display_preset_episode_thumbnails">Náhľadové obrázky epizód</string>
|
||||
<string name="image_type_thumb">Náhľad</string>
|
||||
<string name="add_row">Pridať riadok</string>
|
||||
<string name="genres_in">Žánre v %1$s</string>
|
||||
<string name="recently_released_in">Nedávno vydané v %1$s</string>
|
||||
<string name="height">Výška</string>
|
||||
<string name="apply_all_rows">Použiť na všetky riadky</string>
|
||||
<string name="customize_home">Prispôsobiť domovskú stránku</string>
|
||||
<string name="home_rows">Riadky na domovskej stránke</string>
|
||||
<string name="load_from_server">Načítať zo serverového profilu používateľa</string>
|
||||
<string name="save_to_server">Uložiť do serverového profilu používateľa</string>
|
||||
<string name="load_from_web_client">Načítať z webového klienta</string>
|
||||
<string name="use_series">Použiť obrázok seriálu</string>
|
||||
<string name="add_row_for">Pridať riadok pre %1$s</string>
|
||||
<string name="volume_lowest">Najnižšia</string>
|
||||
<string name="volume_low">Nízka</string>
|
||||
<string name="volume_medium">Stredná</string>
|
||||
<string name="volume_high">Vysoká</string>
|
||||
<string name="volume_full">Plná</string>
|
||||
<string name="purple">Fialová</string>
|
||||
<string name="orange">Oranžová</string>
|
||||
<string name="bold_blue">Výrazná modrá</string>
|
||||
<string name="black">Čierna</string>
|
||||
<string name="skip_ignore">Ignorovať</string>
|
||||
<string name="skip_automatically">Preskočiť automaticky</string>
|
||||
<string name="skip_ask">Opýtať sa na preskočenie</string>
|
||||
<string name="ffmpeg_fallback">Použiť FFmpeg len v prípade, ak neexistuje žiadny vstavaný dekodér</string>
|
||||
<string name="ffmpeg_prefer">Preferovať použitie FFmpeg pred vstavanými dekodérmi</string>
|
||||
<string name="ffmpeg_never">Nikdy nepoužívať FFmpeg dekodéry</string>
|
||||
<string name="next_up_playback_end">Na konci prehrávania</string>
|
||||
<string name="next_up_outro">Počas záverečných titulkov/outro</string>
|
||||
<string name="white">Biela</string>
|
||||
<string name="light_gray">Svetlo šedá</string>
|
||||
<string name="dark_gray">Tmavo šedá</string>
|
||||
<string name="yellow">Žltá</string>
|
||||
<string name="cyan">Azúrová</string>
|
||||
<string name="magenta">Magenta</string>
|
||||
<string name="subtitle_edge_outline">Obrys</string>
|
||||
<string name="subtitle_edge_shadow">Tieň</string>
|
||||
<string name="background_style_wrap">Prispôsobiť pozadie textu</string>
|
||||
<string name="background_style_boxed">Text v rámčeku</string>
|
||||
<string name="prefer_mpv">Uprednostniť MPV</string>
|
||||
<string name="player_backend_options_subtitles_prefer_mpv">Použiť ExoPlayer na prehrávanie HDR</string>
|
||||
<string name="aspect_ratios_poster">Plagát (2:3)</string>
|
||||
<string name="aspect_ratios_16_9">16:9</string>
|
||||
<string name="aspect_ratios_4_3">4:3</string>
|
||||
<string name="aspect_ratios_square">Štvorec (1:1)</string>
|
||||
<string name="image_type_primary">Primárny</string>
|
||||
<string name="backdrop_style_dynamic">Obrázok s dynamickou farbou</string>
|
||||
<string name="backdrop_style_image">Iba obrázok</string>
|
||||
<string name="enter_url_api_key"><![CDATA[Zadajte URL a API kľúč]]></string>
|
||||
<string name="api_key">API kľúč</string>
|
||||
<string name="seerr_login">Prihlásenie na Seerr server</string>
|
||||
<string name="seerr_jellyfin_user">Jellyfin používateľ</string>
|
||||
<string name="seerr_local_user">Lokálny používateľ</string>
|
||||
<string name="search_and_download_subtitles"><![CDATA[Vyhľadať a stiahnuť titulky]]></string>
|
||||
<string name="no_subtitles_found">Nenašli sa žiadne vzdialené titulky</string>
|
||||
<string name="series_continueing">Prebieha</string>
|
||||
<string name="in_app_screensaver">Použiť šetrič obrazovky v aplikácii</string>
|
||||
<string name="delete_item">Naozaj chcete odstrániť túto položku?</string>
|
||||
<string name="show_media_management">Zobraziť možnosti správy médií</string>
|
||||
<string name="actor">Herec</string>
|
||||
<string name="composer">Skladateľ</string>
|
||||
<string name="select_all">Vybrať všetko</string>
|
||||
<string name="discover">Vyhľadať</string>
|
||||
<string name="quick_connect_summary">Autorizujte iné zariadenie na prihlásenie do vášho účtu</string>
|
||||
<string name="send_media_info_log_to_server">Odoslať log informácií o médiách na server</string>
|
||||
<string name="max_days_next_up">Max. počet dní v sekcií Nasleduje</string>
|
||||
<string name="albums">Albumy</string>
|
||||
</resources>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue