Compare commits

..

No commits in common. "365f8f8adacf4dcaf7d05317917830ed3a439668" and "81e5f31401e7e26cb3677248eedafc921bf14b05" have entirely different histories.

372 changed files with 10382 additions and 29013 deletions

View file

@ -1,5 +1,6 @@
name: "Bug Report" name: "Bug Report"
description: Report a bug description: Report a bug
title: "<title>"
labels: [ labels: [
"bug" "bug"
] ]
@ -22,16 +23,6 @@ body:
3. See error 3. See error
validations: validations:
required: false required: false
- type: dropdown
id: experimental
attributes:
label: Experimental settings
description: Do you have experiment settings turn on?
options:
- "Yes"
- "No"
validations:
required: true
- type: input - type: input
id: app-version id: app-version
attributes: attributes:

View file

@ -1,5 +1,6 @@
name: "Playback Problem" name: "Playback Problem"
description: Report an issue with playback description: Report an issue with playback
title: "<title>"
labels: [ labels: [
"bug", "playback" "bug", "playback"
] ]
@ -11,14 +12,12 @@ body:
description: Please enter a description of the playback issue description: Please enter a description of the playback issue
validations: validations:
required: true required: true
- type: dropdown - type: textarea
id: experimental id: mediainfo
attributes: attributes:
label: Experimental settings label: "Media info"
description: Do you have experiment settings turn on? description: Please enter details about what media you are trying to play (video or audio codecs, container, etc)
options: placeholder: Use mediainfo or "Send media info to server" in the app to gather this
- "Yes"
- "No"
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
@ -33,14 +32,6 @@ body:
- External App (please specify) - External App (please specify)
validations: validations:
required: true required: true
- type: textarea
id: mediainfo
attributes:
label: "Media info"
description: Please enter details about what media you are trying to play (video or audio codecs, container, etc)
placeholder: Use mediainfo or "Send media info to server" in the app to gather this
validations:
required: true
- type: input - type: input
id: app-version id: app-version
attributes: attributes:

View file

@ -6,11 +6,11 @@ runs:
steps: steps:
# Setup the SDKs # Setup the SDKs
- name: Set up Python - name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 uses: actions/setup-python@v6
with: with:
python-version: '3.14' python-version: '3.14'
- name: Setup JDK - name: Setup JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 uses: actions/setup-java@v5
with: with:
distribution: zulu distribution: zulu
java-version: '21' java-version: '21'
@ -21,9 +21,9 @@ runs:
echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4 uses: android-actions/setup-android@v4
with: with:
packages: "platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
- name: Add NDK to path - name: Add NDK to path
shell: bash shell: bash
run: | run: |

View file

@ -16,7 +16,7 @@ concurrency:
env: env:
BRANCH_NAME: ${{ github.ref_name }} BRANCH_NAME: ${{ github.ref_name }}
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
jobs: jobs:
build: build:
@ -25,7 +25,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 uses: actions/checkout@v6
with: with:
fetch-depth: 0 # Need the tags to build fetch-depth: 0 # Need the tags to build
- name: Setup - name: Setup
@ -54,8 +54,7 @@ jobs:
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
run: | run: |
./gradlew clean assembleDefaultDebug ./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon
./gradlew assembleDefaultRelease
- name: Verify signatures - name: Verify signatures
run: | run: |
@ -77,7 +76,7 @@ jobs:
echo "SHA256 checksums:" echo "SHA256 checksums:"
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
- name: Upload mapping files - name: Upload mapping files
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 uses: actions/upload-artifact@v7
with: with:
name: mapping name: mapping
path: | path: |

View file

@ -9,29 +9,29 @@ defaults:
env: env:
BUILD_DIRS_ARTIFACT: build-dirs BUILD_DIRS_ARTIFACT: build-dirs
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
jobs: jobs:
pre-commit: pre-commit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 uses: actions/checkout@v6
with: with:
fetch-depth: 0 # Need the tags to build fetch-depth: 0 # Need the tags to build
- name: Setup Python - name: Setup Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 uses: actions/setup-python@v6
with: with:
python-version: '3.14' python-version: '3.14'
- name: Run pre-commit - name: Run pre-commit
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 uses: pre-commit/action@v3.0.1
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: pre-commit needs: pre-commit
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 uses: actions/checkout@v6
with: with:
fetch-depth: 0 # Need the tags to build fetch-depth: 0 # Need the tags to build
- name: Setup - name: Setup

View file

@ -11,7 +11,7 @@ defaults:
env: env:
APK_SHORT_NAME: Wholphin.apk APK_SHORT_NAME: Wholphin.apk
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
TAG_NAME: ${{ github.ref_name }} TAG_NAME: ${{ github.ref_name }}
jobs: jobs:
@ -21,7 +21,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 uses: actions/checkout@v6
with: with:
fetch-depth: 0 # Need the tags to build fetch-depth: 0 # Need the tags to build
- name: Setup - name: Setup
@ -36,8 +36,7 @@ jobs:
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
run: | run: |
./gradlew clean bundleAppstoreRelease ./gradlew clean bundleAppstoreRelease bundleFiretvRelease
./gradlew bundleFiretvRelease
./gradlew assembleDefaultRelease ./gradlew assembleDefaultRelease
- name: Verify signatures - name: Verify signatures
run: | run: |
@ -59,14 +58,14 @@ jobs:
echo "SHA256 checksums:" echo "SHA256 checksums:"
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
- name: Upload AAB - name: Upload AAB
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 uses: actions/upload-artifact@v7
with: with:
name: AAB name: AAB
path: | path: |
app/build/outputs/bundle/**/*.aab app/build/outputs/bundle/**/*.aab
compression-level: 0 compression-level: 0
- name: Upload mapping files - name: Upload mapping files
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 uses: actions/upload-artifact@v7
with: with:
name: mapping name: mapping
path: | path: |

View file

@ -42,8 +42,6 @@ If you want to include these in a local build, see the [instructions here](https
You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry. You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry.
Finally, if no `wholphin-mpv` implementation is found, `wholphin-mpv-stub` will be used. This allows the app to compile, but any runtime usage of MPV will throw an exception.
## Code organization ## Code organization
Code is split into several packages: Code is split into several packages:
@ -97,9 +95,3 @@ Some of the models, such as `GetItemsFilter` or `ViewOptions`, are stored as JSO
These settings are similar to Room ones and are usually stored per user. These can be accessed via `KeyValueService`. The values are `@Serializable` objects saved as JSON. These settings are similar to Room ones and are usually stored per user. These can be accessed via `KeyValueService`. The values are `@Serializable` objects saved as JSON.
This should be used sparingly. This should be used sparingly.
#### Experimental
Some settings are considered "experimental". These may cause instabilities or may need further testing. They may be changed or removed any time.
These are stored in the `ExperimentalPreferences` object in `AppPreferences`. Accessing them should always be gated with `ExperimentalPreferences.enabled`. There are a few helper functions for this purpose.

View file

@ -1,168 +0,0 @@
# Intents
> [!IMPORTANT]
> This feature is experimental and the exact behaviors and/or parameters may change at any time
Wholphin accepts [intents](https://developer.android.com/guide/components/intents-filters) to load or play specified media.
Parameters can be specified as "extras" (eg `--es query value`) or as query parameters to the data URI (eg `-d wholphin://search?query=value`).
UUID parameters can be specified with or without hyphens.
If using data URI query parameters on the command line, you need to escape ampersands: `itemId=...\&userId=...`
## Server/user switching
You can choose a server and user to switch to with the `serverId` and `userId` UUID parameters. If the profile is not protected, the app will switch to it.
If not specified, the app will use the current user if auto sign in is enabled.
## Search
Action: `android.intent.action.SEARCH`
Shorthand action: `search`
Parameters:
- `query` - Optional, prepopulate the search field
### Examples
Full format
```bash
adb shell am start \
-a android.intent.action.SEARCH \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es query "Avengers"
```
URI based
```bash
adb shell am start -d 'wholphin://search?query=Avengers'
```
Multi-word query:
```bash
# Whole shell command is wrapped in double quotes
adb shell "am start \
-a android.intent.action.SEARCH \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es query 'Avengers Endgame'"
adb shell am start -d 'wholphin://search?query=Avengers+Endgame'
adb shell am start -d 'wholphin://search?query=Avengers%20Endgame'
```
## View
This opens the app to the specified item such as an episode or movie
Action: `android.intent.action.VIEW`
Shorthand action: `view`
Parameters:
- `itemId` - Optional, the UUID of the media item, if not set, goes to home screen
### Examples
Full format
```bash
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d"
```
URI based
```bash
adb shell am start -d 'wholphin://view?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d'
```
## Playback
Action: `com.github.damontecres.wholphin.PLAYBACK`
Shorthand action: `play`
Parameters:
- `itemId` - Required, the UUID of the media item
- `position` - Optional, the start position for playback in milliseconds
- `shuffle` - Optional, whether to shuffle the resulting items
### Examples
Full format
```bash
adb shell am start \
-a com.github.damontecres.wholphin.PLAYBACK \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d"
```
URI based
```bash
adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d'
```
Start at 4m30s:
```bash
adb shell am start \
-a com.github.damontecres.wholphin.PLAYBACK \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
--el position 270000
adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&position=270000'
```
Shuffle a series (`itemId` is the Series ID):
```bash
adb shell am start \
-a com.github.damontecres.wholphin.PLAYBACK \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
--ez shuffle true
adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&shuffle=true'
```
## More examples
### Switch user
Full format
```bash
# Switch user, go to item
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
--es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \
--es userId "5c8575e0-468d-44fb-8c70-00aa79160587"
# Switch user, go to home screen
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \
--es userId "5c8575e0-468d-44fb-8c70-00aa79160587"
```
URI based
```bash
adb shell am start -d 'wholphin://view?itemId=66c80aaac9c17d3761782d2205520229\&serverId=341880f9-ca88-4038-8718-f6b2407950b1\&userId=5c8575e0-468d-44fb-8c70-00aa79160587'
```
### Debug build
Debug builds have a different package, so you must specify the component by its full name:
```bash
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin.debug/com.github.damontecres.wholphin.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d"
```

View file

@ -103,7 +103,7 @@ Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x
The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue! The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue!
Seerr integration is generally tested with the latest stable version. Older versions may not work as expected. The Seerr server should configured with integration to your Jellyfin server for full compatibility. Seerr integration is tested with `v3.2.0`. Older versions may not work.
## Contributions ## Contributions

View file

@ -20,21 +20,12 @@ plugins {
alias(libs.plugins.openapi.generator) alias(libs.plugins.openapi.generator)
} }
val isCI = providers.environmentVariable("CI").orElse("false").map { it.toBoolean() } val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false
val shouldSign = val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
isCI.zip( val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
providers.environmentVariable("KEY_ALIAS").orElse("").map { it.isNotBlank() }, val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists()
) { isCI, hasKey -> val mpvModuleExists = project.file("libs/wholphin-mpv-release.aar").exists()
isCI && hasKey val extensionsRepoActive = project.hasProperty("WholphinExtensionsUsername")
}
val ffmpegModuleExists =
providers.provider { project.file("libs/lib-decoder-ffmpeg-release.aar").exists() }
val av1ModuleExists =
providers.provider { project.file("libs/lib-decoder-av1-release.aar").exists() }
val mpvModuleExists =
providers.provider { project.file("libs/wholphin-mpv-release.aar").exists() }
val extensionsRepoActive =
providers.provider { project.hasProperty("WholphinExtensionsUsername") }
// See https://issuetracker.google.com/issues/402800800 // See https://issuetracker.google.com/issues/402800800
val isBuildingBundle = val isBuildingBundle =
@ -59,22 +50,17 @@ kotlin {
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3 languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
jvmTarget = JvmTarget.JVM_11 jvmTarget = JvmTarget.JVM_11
javaParameters = true javaParameters = true
// Workaround for https://github.com/google/ksp/issues/2964
moduleName = "${project.group}_${project.name}"
} }
} }
private fun Provider<String>.getInt() = get().toInt()
configure<ApplicationExtension> { configure<ApplicationExtension> {
namespace = "com.github.damontecres.wholphin" namespace = "com.github.damontecres.wholphin"
compileSdk = libs.versions.compileSdk.getInt() compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.github.damontecres.wholphin" applicationId = "com.github.damontecres.wholphin"
minSdk = libs.versions.minSdk.getInt() minSdk = 23
targetSdk = libs.versions.targetSdk.getInt() targetSdk = 36
versionCode = gitTags.trim().lines().size versionCode = gitTags.trim().lines().size
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
@ -83,7 +69,7 @@ configure<ApplicationExtension> {
} }
signingConfigs { signingConfigs {
if (shouldSign.get()) { if (shouldSign) {
create("ci") { create("ci") {
file("ci.keystore").writeBytes( file("ci.keystore").writeBytes(
Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), Base64.getDecoder().decode(System.getenv("SIGNING_KEY")),
@ -109,7 +95,7 @@ configure<ApplicationExtension> {
"proguard-rules.pro", "proguard-rules.pro",
) )
isDebuggable = false isDebuggable = false
if (shouldSign.get()) { if (shouldSign) {
signingConfig = signingConfigs.getByName("ci") signingConfig = signingConfigs.getByName("ci")
} else { } else {
val localPropertiesFile = project.rootProject.file("local.properties") val localPropertiesFile = project.rootProject.file("local.properties")
@ -180,7 +166,7 @@ configure<ApplicationExtension> {
isEnable = !isBuildingBundle.get() isEnable = !isBuildingBundle.get()
reset() reset()
include("armeabi-v7a", "arm64-v8a", "x86_64") include("armeabi-v7a", "arm64-v8a")
isUniversalApk = true isUniversalApk = true
} }
} }
@ -337,6 +323,7 @@ dependencies {
implementation(libs.androidx.room.common.jvm) implementation(libs.androidx.room.common.jvm)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.preference.ktx) implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.room.testing) implementation(libs.androidx.room.testing)
@ -364,39 +351,40 @@ dependencies {
implementation(libs.kache) implementation(libs.kache)
implementation(libs.kache.file) implementation(libs.kache.file)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
coreLibraryDesugaring(libs.desugar.jdk.libs) coreLibraryDesugaring(libs.desugar.jdk.libs)
if (ffmpegModuleExists.get()) { if (ffmpegModuleExists) {
logger.info("Using local ffmpeg decoder") logger.info("Using local ffmpeg decoder")
implementation(files("libs/lib-decoder-ffmpeg-release.aar")) implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
} else if (extensionsRepoActive.get()) { } else if (extensionsRepoActive) {
logger.info("Using prebuilt ffmpeg decoder") logger.info("Using prebuilt ffmpeg decoder")
implementation(libs.wholphin.extensions.ffmpeg) implementation(libs.wholphin.extensions.ffmpeg)
} else { } else {
logger.warn("Media3 ffmpeg decoder was NOT found") logger.warn("Media3 ffmpeg decoder was NOT found")
} }
if (av1ModuleExists.get()) { if (av1ModuleExists) {
logger.info("Using local av1 decoder") logger.info("Using local av1 decoder")
implementation(files("libs/lib-decoder-av1-release.aar")) implementation(files("libs/lib-decoder-av1-release.aar"))
} else if (extensionsRepoActive.get()) { } else if (extensionsRepoActive) {
logger.info("Using prebuilt av1 decoder") logger.info("Using prebuilt av1 decoder")
implementation(libs.wholphin.extensions.av1) implementation(libs.wholphin.extensions.av1)
} else { } else {
logger.warn("Media3 av1 decoder was NOT found") logger.warn("Media3 av1 decoder was NOT found")
} }
if (mpvModuleExists.get()) { if (mpvModuleExists) {
logger.info("Using local libMPV build") logger.info("Using local libMPV build")
implementation(files("libs/wholphin-mpv-release.aar")) implementation(files("libs/wholphin-mpv-release.aar"))
} else if (extensionsRepoActive.get()) { } else if (extensionsRepoActive) {
logger.info("Using prebuilt libMPV") logger.info("Using prebuilt libMPV")
implementation(libs.wholphin.extensions.mpv) implementation(libs.wholphin.extensions.mpv)
} else { } else {
logger.warn("libMPV was NOT found, using stub library") logger.warn("libMPV was NOT found")
implementation(project(":wholphin-mpv-stub"))
} }
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.mockk.android) testImplementation(libs.mockk.android)
testImplementation(libs.mockk.agent) testImplementation(libs.mockk.agent)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
@ -404,8 +392,7 @@ dependencies {
testImplementation(libs.robolectric) testImplementation(libs.robolectric)
testImplementation(libs.hilt.android.testing) testImplementation(libs.hilt.android.testing)
testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.mockk.android) androidTestImplementation(libs.mockk.android)
androidTestImplementation(libs.hilt.android.testing) androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.androidx.compose.ui.test.manifest) androidTestImplementation(libs.androidx.ui.test.manifest)
} }

View file

@ -1,6 +1,6 @@
-keepattributes SourceFile,LineNumberTable -keepattributes SourceFile,LineNumberTable
-keep class com.github.damontecres.wholphin.mpv.MPVLib { *; } -keep class com.github.damontecres.wholphin.util.mpv.* { *; }
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; } -keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { -keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
@ -11,14 +11,3 @@
# TODO investigate using smaller scope # TODO investigate using smaller scope
-keep class com.google.common.cache.** { *; } -keep class com.google.common.cache.** { *; }
# Media3SubtitleOverride uses reflection to access/modify these fields
-keep class androidx.media3.ui.SubtitleView {
private androidx.media3.ui.SubtitleView$Output output;
}
-keep class androidx.media3.ui.CanvasSubtitleOutput {
private final java.util.List painters;
}
-keep class androidx.media3.ui.SubtitlePainter {
private final float outlineWidth;
}

View file

@ -1,720 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 34,
"identityHash": "278e485064fc9f184a2181599f0f8efb",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT"
},
{
"fieldPath": "pin",
"columnName": "pin",
"affinity": "TEXT"
},
{
"fieldPath": "requireLogin",
"columnName": "requireLogin",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"affinity": "TEXT"
},
{
"fieldPath": "uiLanguage",
"columnName": "uiLanguage",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_users_id_serverId",
"unique": true,
"columnNames": [
"id",
"serverId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
},
{
"name": "index_users_id",
"unique": false,
"columnNames": [
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
},
{
"name": "index_users_serverId",
"unique": false,
"columnNames": [
"serverId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
}
],
"foreignKeys": [
{
"table": "servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "ItemPlayback",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "TEXT"
},
{
"fieldPath": "audioIndex",
"columnName": "audioIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subtitleIndex",
"columnName": "subtitleIndex",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_ItemPlayback_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "NavDrawerPinnedItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "LibraryDisplayInfo",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sort",
"columnName": "sort",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "direction",
"columnName": "direction",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filter",
"columnName": "filter",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'{}'"
},
{
"fieldPath": "viewOptions",
"columnName": "viewOptions",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_LibraryDisplayInfo_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "playback_effects",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "videoFilter.rotation",
"columnName": "rotation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.brightness",
"columnName": "brightness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.contrast",
"columnName": "contrast",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.saturation",
"columnName": "saturation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.hue",
"columnName": "hue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.red",
"columnName": "red",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.green",
"columnName": "green",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blue",
"columnName": "blue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blur",
"columnName": "blur",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"itemId",
"type"
]
}
},
{
"tableName": "PlaybackLanguageChoice",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "seriesId",
"columnName": "seriesId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT"
},
{
"fieldPath": "audioLanguage",
"columnName": "audioLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitleLanguage",
"columnName": "subtitleLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitlesDisabled",
"columnName": "subtitlesDisabled",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"seriesId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "ItemTrackModification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackIndex",
"columnName": "trackIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "delayMs",
"columnName": "delayMs",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId",
"trackIndex"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "seerr_servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_seerr_servers_url",
"unique": true,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
}
]
},
{
"tableName": "seerr_users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authMethod",
"columnName": "authMethod",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT"
},
{
"fieldPath": "credential",
"columnName": "credential",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"serverId"
]
},
"foreignKeys": [
{
"table": "seerr_servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
},
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"jellyfinUserRowId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "RememberedTab",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_RememberedTab_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '278e485064fc9f184a2181599f0f8efb')"
]
}
}

View file

@ -1,741 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 35,
"identityHash": "ff9ade58d4ac39e36335ac0df1405a5f",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT"
},
{
"fieldPath": "pin",
"columnName": "pin",
"affinity": "TEXT"
},
{
"fieldPath": "requireLogin",
"columnName": "requireLogin",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"affinity": "TEXT"
},
{
"fieldPath": "uiLanguage",
"columnName": "uiLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "appPreferences.preferredAudioLanguage",
"columnName": "preferredAudioLanguage",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "appPreferences.preferredSubtitleLanguage",
"columnName": "preferredSubtitleLanguage",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "appPreferences.subtitleMode",
"columnName": "subtitleMode",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'USE_USER_PROFILE'"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_users_id_serverId",
"unique": true,
"columnNames": [
"id",
"serverId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
},
{
"name": "index_users_id",
"unique": false,
"columnNames": [
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
},
{
"name": "index_users_serverId",
"unique": false,
"columnNames": [
"serverId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
}
],
"foreignKeys": [
{
"table": "servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "ItemPlayback",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "TEXT"
},
{
"fieldPath": "audioIndex",
"columnName": "audioIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subtitleIndex",
"columnName": "subtitleIndex",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_ItemPlayback_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "NavDrawerPinnedItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "LibraryDisplayInfo",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sort",
"columnName": "sort",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "direction",
"columnName": "direction",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filter",
"columnName": "filter",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'{}'"
},
{
"fieldPath": "viewOptions",
"columnName": "viewOptions",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_LibraryDisplayInfo_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "playback_effects",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "videoFilter.rotation",
"columnName": "rotation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.brightness",
"columnName": "brightness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.contrast",
"columnName": "contrast",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.saturation",
"columnName": "saturation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.hue",
"columnName": "hue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.red",
"columnName": "red",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.green",
"columnName": "green",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blue",
"columnName": "blue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blur",
"columnName": "blur",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"itemId",
"type"
]
}
},
{
"tableName": "PlaybackLanguageChoice",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "seriesId",
"columnName": "seriesId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT"
},
{
"fieldPath": "audioLanguage",
"columnName": "audioLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitleLanguage",
"columnName": "subtitleLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitlesDisabled",
"columnName": "subtitlesDisabled",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"seriesId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "ItemTrackModification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackIndex",
"columnName": "trackIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "delayMs",
"columnName": "delayMs",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId",
"trackIndex"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "seerr_servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_seerr_servers_url",
"unique": true,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
}
]
},
{
"tableName": "seerr_users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authMethod",
"columnName": "authMethod",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT"
},
{
"fieldPath": "credential",
"columnName": "credential",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"serverId"
]
},
"foreignKeys": [
{
"table": "seerr_servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
},
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"jellyfinUserRowId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "RememberedTab",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_RememberedTab_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ff9ade58d4ac39e36335ac0df1405a5f')"
]
}
}

View file

@ -4,7 +4,6 @@
android:installLocation="auto"> android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
@ -52,21 +51,13 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|density"> android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.SEARCH" />
<action android:name="com.github.damontecres.wholphin.PLAYBACK" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="wholphin" />
</intent-filter>
</activity> </activity>
<provider <provider

View file

@ -6,7 +6,6 @@ import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@ -28,8 +27,6 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
@ -38,8 +35,6 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.IntentResult
import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
@ -50,7 +45,6 @@ import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.services.SuggestionsSchedulerService import com.github.damontecres.wholphin.services.SuggestionsSchedulerService
import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.UserSwitchListener
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
@ -58,20 +52,19 @@ import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.ui.components.LoadingPage 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.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.requestSerializersModule
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -79,15 +72,14 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@ -97,9 +89,6 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var userPreferencesDataStore: DataStore<AppPreferences> lateinit var userPreferencesDataStore: DataStore<AppPreferences>
@Inject
lateinit var userPreferencesService: UserPreferencesService
@AuthOkHttpClient @AuthOkHttpClient
@Inject @Inject
lateinit var okHttpClient: OkHttpClient lateinit var okHttpClient: OkHttpClient
@ -148,16 +137,11 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var screensaverService: ScreensaverService lateinit var screensaverService: ScreensaverService
@Inject
lateinit var intentService: IntentService
private var signInAuto = true private var signInAuto = true
private var playerBackend: PlayerBackend? = null
private val json = private val json =
Json { Json {
classDiscriminator = "_type" classDiscriminator = "_type"
serializersModule = requestSerializersModule
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@ -168,51 +152,37 @@ class MainActivity : AppCompatActivity() {
lifecycle.addObserver(playbackLifecycleObserver) lifecycle.addObserver(playbackLifecycleObserver)
val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK) val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK)
val restoredBackStack =
if (backStackStr != null) { if (backStackStr != null) {
try {
json.decodeFromString<List<Destination>>(backStackStr)
} catch (ex: Exception) {
// Best-effort: a previously persisted back stack we can no longer decode
// must not crash startup; fall back to a fresh start destination.
Timber.w(ex, "Could not restore back stack; starting fresh")
null
}
} else {
null
}
if (restoredBackStack != null) {
Timber.d("Restoring back stack") Timber.d("Restoring back stack")
var backStack = restoredBackStack var backStack = json.decodeFromString<List<Destination>>(backStackStr)
if (!playExternalViewModel.launched.value) { if (!playExternalViewModel.launched.value) {
val lastDest = backStack.lastOrNull() val lastDest = backStack.lastOrNull()
if (lastDest.isPlayback) { if (lastDest.isPlayback) {
Timber.v("Restoring back stack without playback") Timber.v("Restoring back stack with playback")
backStack = backStack.toMutableList().apply { removeAt(lastIndex) } backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
} }
} }
navigationManager.backStack = NavBackStack(*backStack.toTypedArray()) navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
} else { } else {
navigationManager.backStack = NavBackStack(Destination.Home()) val startDestination = intent?.let(::extractDestination) ?: Destination.Home()
navigationManager.backStack = NavBackStack(startDestination)
} }
viewModel.serverRepository.currentUserFlow viewModel.serverRepository.currentUserFlow.collectLatestIn(lifecycleScope) { user ->
.onEach { user ->
withContext(WholphinDispatchers.Main) {
if (user?.hasPin == true) { if (user?.hasPin == true) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) window?.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE,
)
} else { } else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
} }
} }
}.catch { ex ->
Timber.e(ex, "Error with settings flag secure")
}.launchIn(lifecycleScope)
screensaverService.keepScreenOn screensaverService.keepScreenOn
.onEach { keepScreenOn -> .onEach { keepScreenOn ->
Timber.v("keepScreenOn: %s", keepScreenOn) Timber.v("keepScreenOn: %s", keepScreenOn)
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
if (keepScreenOn) { if (keepScreenOn) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else { } else {
@ -223,21 +193,14 @@ class MainActivity : AppCompatActivity() {
Timber.e(ex, "Error with keepScreenOn") Timber.e(ex, "Error with keepScreenOn")
}.launchIn(lifecycleScope) }.launchIn(lifecycleScope)
userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs -> viewModel.appStart()
signInAuto = prefs.signInAutomatically
playerBackend = prefs.playbackPreferences.playerBackend
}
viewModel.appStart(intent)
setContent { setContent {
MaterialTheme(colorScheme = PurpleThemeColors.darkScheme) { val appPreferences by userPreferencesDataStore.data.collectAsState(null)
Surface(Modifier.fillMaxSize()) { if (appPreferences == null) {
val userPreferences by userPreferencesService.flow.collectAsState(null)
if (userPreferences == null) {
// Show loading page if it is taking a while to get app preferences // Show loading page if it is taking a while to get app preferences
var showLoading by remember { mutableStateOf(false) } var showLoading by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(500.milliseconds) delay(500)
Timber.i("Showing loading page") Timber.i("Showing loading page")
showLoading = true showLoading = true
} }
@ -251,9 +214,11 @@ class MainActivity : AppCompatActivity() {
LoadingPage() LoadingPage()
} }
} }
} else { }
userPreferences?.let { userPreferences -> appPreferences?.let { appPreferences ->
val appPreferences = userPreferences.appPreferences LaunchedEffect(appPreferences.signInAutomatically) {
signInAuto = appPreferences.signInAutomatically
}
CoilConfig( CoilConfig(
prefs = appPreferences, prefs = appPreferences,
okHttpClient = okHttpClient, okHttpClient = okHttpClient,
@ -272,7 +237,7 @@ class MainActivity : AppCompatActivity() {
MainContent( MainContent(
backStack = setupNavigationManager.backStack, backStack = setupNavigationManager.backStack,
navigationManager = navigationManager, navigationManager = navigationManager,
userPreferences = userPreferences, appPreferences = appPreferences,
backdropService = backdropService, backdropService = backdropService,
screensaverService = screensaverService, screensaverService = screensaverService,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -283,12 +248,9 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
}
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (screensaverService.state.value.run { show || showDim }) { if (screensaverService.state.value.show) {
screensaverService.stop(false) screensaverService.stop(false)
screensaverService.pulse() screensaverService.pulse()
return true return true
@ -301,7 +263,6 @@ class MainActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
Timber.d("onResume") Timber.d("onResume")
viewModel.appResume()
lifecycleScope.launchDefault { lifecycleScope.launchDefault {
screensaverService.pulse() screensaverService.pulse()
} }
@ -310,7 +271,7 @@ class MainActivity : AppCompatActivity() {
override fun onRestart() { override fun onRestart() {
super.onRestart() super.onRestart()
Timber.d("onRestart") Timber.d("onRestart")
viewModel.appStart(null) viewModel.appStart()
if (!playExternalViewModel.launched.value) { if (!playExternalViewModel.launched.value) {
// If restarting during playback that is not external, go back a page // If restarting during playback that is not external, go back a page
val lastDest = navigationManager.backStack.lastOrNull() val lastDest = navigationManager.backStack.lastOrNull()
@ -357,14 +318,10 @@ class MainActivity : AppCompatActivity() {
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
Timber.d("onSaveInstanceState") Timber.d("onSaveInstanceState")
try {
val str = json.encodeToString(navigationManager.backStack.toList()) val str = json.encodeToString(navigationManager.backStack.toList())
outState.putString(KEY_BACK_STACK, str) outState.putString(KEY_BACK_STACK, str)
} catch (ex: Exception) { val playerBackend =
// Best-effort: some destinations carry types we can't serialize; skip persisting runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend
// the back stack rather than crashing in onSaveInstanceState.
Timber.w(ex, "Could not persist back stack; skipping")
}
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER) outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
} }
@ -380,34 +337,67 @@ class MainActivity : AppCompatActivity() {
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
Timber.d("onConfigurationChanged: newConfig=%s", newConfig) Timber.d("onConfigurationChanged")
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
Timber.v("onNewIntent") Timber.v("onNewIntent")
setIntent(intent) setIntent(intent)
viewModel.appStart(intent) extractDestination(intent)?.let {
navigationManager.replace(it)
}
}
private fun extractDestination(intent: Intent): Destination? =
intent.let {
val itemId =
it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull()
val type =
it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull)
if (itemId != null && type != null) {
val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull()
val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull()
val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1)
val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1)
if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) {
Destination.SeriesOverview(
itemId = seriesId,
type = BaseItemKind.SERIES,
seasonEpisode =
SeasonEpisodeIds(
seasonId = seasonId,
seasonNumber = seasonNumber,
episodeId = itemId,
episodeNumber = episodeNumber,
),
)
} else {
Destination.MediaItem(itemId, type)
}
} else {
null
}
} }
fun changeDisplayMode(modeId: Int) { fun changeDisplayMode(modeId: Int) {
lifecycleScope.launch(WholphinDispatchers.Main + ExceptionHandler(autoToast = true)) { lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) {
try {
val attrs = window.attributes val attrs = window.attributes
if (attrs.preferredDisplayModeId != modeId) { if (attrs.preferredDisplayModeId != modeId) {
Timber.d("Switch preferredDisplayModeId to %s", modeId) Timber.d("Switch preferredDisplayModeId to %s", modeId)
window.attributes = attrs.apply { preferredDisplayModeId = modeId } window.attributes = attrs.apply { preferredDisplayModeId = modeId }
} }
} catch (ex: Exception) {
Timber.e(ex, "Error switching preferredDisplayModeId to %s", modeId)
Toast
.makeText(this@MainActivity, "Error changing display mode", Toast.LENGTH_SHORT)
.show()
}
} }
} }
companion object { companion object {
const val INTENT_ITEM_ID = "itemId"
const val INTENT_ITEM_TYPE = "itemType"
const val INTENT_SERIES_ID = "seriesId"
const val INTENT_EPISODE_NUMBER = "epNum"
const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId"
private const val KEY_BACK_STACK = "backStack" private const val KEY_BACK_STACK = "backStack"
private const val KEY_EXTERNAL_PLAYER = "extPlayer" private const val KEY_EXTERNAL_PLAYER = "extPlayer"
@ -423,69 +413,13 @@ class MainActivityViewModel
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val preferences: DataStore<AppPreferences>, private val preferences: DataStore<AppPreferences>,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
private val setupNavigationManager: SetupNavigationManager, private val navigationManager: SetupNavigationManager,
private val navigationManager: NavigationManager,
private val deviceProfileService: DeviceProfileService, private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler, private val appUpgradeHandler: AppUpgradeHandler,
private val intentService: IntentService,
) : ViewModel() { ) : ViewModel() {
private val mutex = Mutex() fun appStart() {
fun appStart(intent: Intent?) {
viewModelScope.launchDefault { viewModelScope.launchDefault {
mutex.withLock {
try {
val result = intent?.let { intentService.parseIntent(intent) }
when (result) {
is IntentResult.Error -> {
val current = serverRepository.current.value
val destination =
if (current != null) {
SetupDestination.UserList(current.server)
} else {
SetupDestination.ServerList
}
setupNavigationManager.navigateTo(destination)
Timber.e("Error parsing intent: %s", result.message)
showToast(context, result.message)
return@withLock
}
is IntentResult.Target -> {
val current = serverRepository.current.value
if (current != null) {
Timber.i("Received valid intent, switching to AppContent")
if (result.addHomeToBackStack) {
navigationManager.reloadHome()
} else {
navigationManager.backStack.clear()
}
navigationManager.backStack.addAll(result.destinations)
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
} else {
// This should never happen, but just reset app state if it does
setupNavigationManager.navigateTo(SetupDestination.ServerList)
Timber.e("Error parsing intent, no user is active")
showToast(context, "An error occurred parsing the intent")
}
return@withLock
}
IntentResult.NoOp,
null,
-> {
// No-op, proceed below
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error parsing intent")
}
try { try {
val needUpgrade = appUpgradeHandler.needUpgrade() val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) { if (needUpgrade) {
@ -502,9 +436,9 @@ class MainActivityViewModel
val prefs = val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val profileProtected = val profileProtected =
serverRepository.current.value serverRepository.current.value?.user?.let {
?.user it.hasPin || it.requireLogin
?.isProtected == true } == true
if (prefs.signInAutomatically && !profileProtected) { if (prefs.signInAutomatically && !profileProtected) {
val current = val current =
serverRepository.restoreSession( serverRepository.restoreSession(
@ -512,48 +446,35 @@ class MainActivityViewModel
prefs.currentUserId?.toUUIDOrNull(), prefs.currentUserId?.toUUIDOrNull(),
) )
if (current != null) { if (current != null) {
if (current.user.isProtected) { if (current.user.hasPin || current.user.requireLogin) {
setupNavigationManager.navigateTo( navigationManager.navigateTo(SetupDestination.UserList(current.server))
SetupDestination.UserList(
current.server,
),
)
} else { } else {
// Restored // Restored
setupNavigationManager.navigateTo( navigationManager.navigateTo(SetupDestination.AppContent(current))
SetupDestination.AppContent(
current,
),
)
} }
} else { } else {
// Did not restore // Did not restore
setupNavigationManager.navigateTo(SetupDestination.ServerList) navigationManager.navigateTo(SetupDestination.ServerList)
} }
} else { } else {
setupNavigationManager.navigateTo(SetupDestination.Loading) navigationManager.navigateTo(SetupDestination.Loading)
backdropService.clearBackdrop() backdropService.clearBackdrop()
val currentServerId = prefs.currentServerId?.toUUIDOrNull() val currentServerId = prefs.currentServerId?.toUUIDOrNull()
if (currentServerId != null) { if (currentServerId != null) {
val currentServer = val currentServer =
serverRepository.serverDao.getServer(currentServerId)?.server serverRepository.serverDao.getServer(currentServerId)?.server
if (currentServer != null) { if (currentServer != null) {
setupNavigationManager.navigateTo( navigationManager.navigateTo(SetupDestination.UserList(currentServer))
SetupDestination.UserList(
currentServer,
),
)
} else { } else {
setupNavigationManager.navigateTo(SetupDestination.ServerList) navigationManager.navigateTo(SetupDestination.ServerList)
} }
} else { } else {
setupNavigationManager.navigateTo(SetupDestination.ServerList) navigationManager.navigateTo(SetupDestination.ServerList)
} }
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error during appStart") Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList) navigationManager.navigateTo(SetupDestination.ServerList)
}
} }
} }
viewModelScope.launchDefault { viewModelScope.launchDefault {
@ -561,19 +482,6 @@ class MainActivityViewModel
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC() deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
} }
} }
fun appResume() {
viewModelScope.launchDefault {
mutex.withLock {
try {
Timber.v("Updating userDto")
serverRepository.updateUserDto()
} catch (ex: Exception) {
Timber.w(ex, "Error during appResume")
}
}
}
}
} }
private val Destination?.isPlayback: Boolean private val Destination?.isPlayback: Boolean

View file

@ -17,7 +17,6 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -28,6 +27,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
@ -44,12 +44,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
fun MainContent( fun MainContent(
backStack: MutableList<SetupDestination>, backStack: MutableList<SetupDestination>,
navigationManager: NavigationManager, navigationManager: NavigationManager,
userPreferences: UserPreferences, appPreferences: AppPreferences,
backdropService: BackdropService, backdropService: BackdropService,
screensaverService: ScreensaverService, screensaverService: ScreensaverService,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val preferences by rememberUpdatedState(userPreferences) val preferences by rememberUpdatedState(UserPreferences(appPreferences))
Surface( Surface(
modifier = modifier =
modifier modifier
@ -59,7 +59,7 @@ fun MainContent(
// val backStack = rememberNavBackStack(SetupDestination.Loading) // val backStack = rememberNavBackStack(SetupDestination.Loading)
// setupNavigationManager.backStack = backStack // setupNavigationManager.backStack = backStack
val interfaceCustomization = val interfaceCustomization =
remember(userPreferences.appPreferences) { InterfaceCustomization(userPreferences.appPreferences) } remember(appPreferences) { InterfaceCustomization(appPreferences) }
CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) { CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) {
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
@ -105,7 +105,7 @@ fun MainContent(
mutableStateOf(true) mutableStateOf(true)
} }
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
if (!userPreferences.appPreferences.signInAutomatically) { if (!appPreferences.signInAutomatically) {
showContent = false showContent = false
} }
} }
@ -137,28 +137,12 @@ fun MainContent(
val screenSaverState by screensaverService.state.collectAsState() val screenSaverState by screensaverService.state.collectAsState()
if (screenSaverState.enabled || screenSaverState.enabledTemp) { if (screenSaverState.enabled || screenSaverState.enabledTemp) {
AnimatedVisibility( AnimatedVisibility(
visible = screenSaverState.show, screenSaverState.show,
enter = ScreensaverService.enterAnimation, Modifier.fillMaxSize(),
exit = ScreensaverService.exitAnimation,
modifier = Modifier.fillMaxSize(),
) { ) {
AppScreensaver(userPreferences.appPreferences, Modifier.fillMaxSize()) AppScreensaver(appPreferences, Modifier.fillMaxSize())
} }
} }
AnimatedVisibility(
visible = screenSaverState.showDim || (screenSaverState.dimEnabled && screenSaverState.show),
enter = ScreensaverService.enterAnimation,
exit = ScreensaverService.exitAnimation,
modifier = Modifier.fillMaxSize(),
) {
val alpha =
userPreferences.appPreferences.interfacePreferences.screensaverPreference.dimPercent / 100f
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = alpha)),
)
}
} }
} }
} }

View file

@ -15,8 +15,6 @@ import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
import org.acra.data.StringFormat import org.acra.data.StringFormat
import org.acra.ktx.initAcra import org.acra.ktx.initAcra
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.model.ServerVersion
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -122,7 +120,5 @@ class WholphinApplication :
companion object { companion object {
lateinit var instance: WholphinApplication lateinit var instance: WholphinApplication
private set private set
val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion
} }
} }

View file

@ -1,8 +1,6 @@
package com.github.damontecres.wholphin package com.github.damontecres.wholphin
import android.service.dreams.DreamService import android.service.dreams.DreamService
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -11,7 +9,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -107,7 +104,6 @@ class WholphinDreamService :
ProvideLocalClock { ProvideLocalClock {
val screensaverPrefs = prefs.interfacePreferences.screensaverPreference val screensaverPrefs = prefs.interfacePreferences.screensaverPreference
val currentItem by itemFlow.collectAsState(null) val currentItem by itemFlow.collectAsState(null)
Box(Modifier.fillMaxSize()) {
AppScreensaverContent( AppScreensaverContent(
currentItem = currentItem, currentItem = currentItem,
showClock = screensaverPrefs.showClock, showClock = screensaverPrefs.showClock,
@ -115,15 +111,6 @@ class WholphinDreamService :
animate = screensaverPrefs.animate, animate = screensaverPrefs.animate,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
if (screensaverPrefs.dimEnabled) {
val alpha = screensaverPrefs.dimPercent / 100f
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = alpha)),
)
}
}
} }
} }
} }

View file

@ -16,7 +16,6 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.PlaybackEffect import com.github.damontecres.wholphin.data.model.PlaybackEffect
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.RememberedTab
import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.components.ViewOptions
@ -47,9 +46,8 @@ import java.util.UUID
ItemTrackModification::class, ItemTrackModification::class,
SeerrServer::class, SeerrServer::class,
SeerrUser::class, SeerrUser::class,
RememberedTab::class,
], ],
version = 35, version = 33,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(3, 4), AutoMigration(3, 4),
@ -66,8 +64,6 @@ import java.util.UUID
AutoMigration(30, 31), AutoMigration(30, 31),
AutoMigration(31, 32), AutoMigration(31, 32),
AutoMigration(32, 33), AutoMigration(32, 33),
AutoMigration(33, 34),
AutoMigration(34, 35),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -85,8 +81,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun seerrServerDao(): SeerrServerDao abstract fun seerrServerDao(): SeerrServerDao
abstract fun playbackEffectDao(): PlaybackEffectDao abstract fun playbackEffectDao(): PlaybackEffectDao
abstract fun rememberedTabDao(): RememberedTabDao
} }
class Converters { class Converters {

View file

@ -27,7 +27,6 @@ sealed interface ExtrasItem {
val subtitle: String? val subtitle: String?
val isPlayed: Boolean val isPlayed: Boolean
val playedPercentage: Double val playedPercentage: Double
val aspectRatio: Float?
/** /**
* Represents multiple extras of the same type * Represents multiple extras of the same type
@ -40,7 +39,6 @@ sealed interface ExtrasItem {
override val title: String, override val title: String,
override val subtitle: String, override val subtitle: String,
override val isPlayed: Boolean, override val isPlayed: Boolean,
override val aspectRatio: Float?,
) : ExtrasItem { ) : ExtrasItem {
override val destination: Destination = override val destination: Destination =
Destination.ItemGrid( Destination.ItemGrid(
@ -84,8 +82,6 @@ sealed interface ExtrasItem {
get() = item.played get() = item.played
override val playedPercentage override val playedPercentage
get() = item.data.userData?.playedPercentage ?: 0.0 get() = item.data.userData?.playedPercentage ?: 0.0
override val aspectRatio: Float?
get() = item.aspectRatio
} }
} }
@ -96,18 +92,18 @@ sealed interface ExtrasItem {
val ExtraType.stringRes: Int val ExtraType.stringRes: Int
get() = get() =
when (this) { when (this) {
ExtraType.UNKNOWN -> R.string.other_extras_title ExtraType.UNKNOWN -> R.string.other_extras
ExtraType.CLIP -> R.string.clips_title ExtraType.CLIP -> R.string.clips
ExtraType.TRAILER -> R.string.trailers_title ExtraType.TRAILER -> R.string.trailers
ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes_title ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes
ExtraType.DELETED_SCENE -> R.string.deleted_scenes_title ExtraType.DELETED_SCENE -> R.string.deleted_scenes
ExtraType.INTERVIEW -> R.string.interviews_title ExtraType.INTERVIEW -> R.string.interviews
ExtraType.SCENE -> R.string.scenes_title ExtraType.SCENE -> R.string.scenes
ExtraType.SAMPLE -> R.string.samples_title ExtraType.SAMPLE -> R.string.samples
ExtraType.THEME_SONG -> R.string.theme_songs_title ExtraType.THEME_SONG -> R.string.theme_songs
ExtraType.THEME_VIDEO -> R.string.theme_videos_title ExtraType.THEME_VIDEO -> R.string.theme_videos
ExtraType.FEATURETTE -> R.string.featurettes_title ExtraType.FEATURETTE -> R.string.featurettes
ExtraType.SHORT -> R.string.shorts_title ExtraType.SHORT -> R.string.shorts
} }
/** /**

View file

@ -7,7 +7,7 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStream
@ -19,9 +19,6 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration import kotlin.time.Duration
/**
* Wrapper for [ItemPlaybackDao] to save [ItemPlayback] objects and also convert them into [ChosenStreams]
*/
@Singleton @Singleton
class ItemPlaybackRepository class ItemPlaybackRepository
@Inject @Inject
@ -31,13 +28,6 @@ class ItemPlaybackRepository
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
private val streamChoiceService: StreamChoiceService, private val streamChoiceService: StreamChoiceService,
) { ) {
/**
* Get the chosen source & stream for the given item
*
* This will be either the explicit choice from the user ([ItemPlayback]) or the default choices
*
* @see getChosenItemFromPlayback
*/
suspend fun getSelectedTracks( suspend fun getSelectedTracks(
itemId: UUID, itemId: UUID,
item: BaseItem, item: BaseItem,
@ -50,11 +40,6 @@ class ItemPlaybackRepository
return getChosenItemFromPlayback(item, itemPlayback, plc, prefs) return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
} }
/**
* Get the chosen source & stream for the given item
*
* This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null
*/
fun getChosenItemFromPlayback( fun getChosenItemFromPlayback(
item: BaseItem, item: BaseItem,
itemPlayback: ItemPlayback?, itemPlayback: ItemPlayback?,
@ -101,14 +86,11 @@ class ItemPlaybackRepository
} }
} }
/**
* Save the choice for a specific media source for an item
*/
suspend fun savePlayVersion( suspend fun savePlayVersion(
itemId: UUID, itemId: UUID,
sourceId: UUID, sourceId: UUID,
): ItemPlayback? = ): ItemPlayback? =
withContext(WholphinDispatchers.IO) { withContext(Dispatchers.IO) {
serverRepository.currentUser?.let { user -> serverRepository.currentUser?.let { user ->
val itemPlayback = val itemPlayback =
ItemPlayback( ItemPlayback(
@ -121,11 +103,6 @@ class ItemPlaybackRepository
} }
} }
/**
* Save the chosen track (index & type) for an item
*
* Additionally, if the item is part of a TV Series, store the language as the preferred one for this series
*/
suspend fun saveTrackSelection( suspend fun saveTrackSelection(
item: BaseItem, item: BaseItem,
itemPlayback: ItemPlayback?, itemPlayback: ItemPlayback?,
@ -203,9 +180,6 @@ class ItemPlaybackRepository
return toSave.copy(rowId = id) return toSave.copy(rowId = id)
} }
/**
* Wrapper for [ItemPlaybackDao.getTrackModifications] to get the [ItemTrackModification] for an item, if any
*/
suspend fun getTrackModifications( suspend fun getTrackModifications(
itemId: UUID, itemId: UUID,
trackIndex: Int, trackIndex: Int,
@ -214,9 +188,6 @@ class ItemPlaybackRepository
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex) itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
} }
/**
* Create and save a [ItemTrackModification] via [ItemPlaybackDao.saveItem]
*/
suspend fun saveTrackModifications( suspend fun saveTrackModifications(
itemId: UUID, itemId: UUID,
trackIndex: Int, trackIndex: Int,
@ -235,9 +206,6 @@ class ItemPlaybackRepository
} }
} }
/**
* Clear the user's source/stream choices for an item
*/
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) { suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
Timber.d("deleteChosenStreams: %s", chosenStreams) Timber.d("deleteChosenStreams: %s", chosenStreams)
chosenStreams?.plc?.let { chosenStreams?.plc?.let {
@ -251,9 +219,6 @@ class ItemPlaybackRepository
} }
} }
/**
* Info about which media source and streams the user/app has chosen for playback
*/
data class ChosenStreams( data class ChosenStreams(
val itemPlayback: ItemPlayback?, val itemPlayback: ItemPlayback?,
val plc: PlaybackLanguageChoice?, val plc: PlaybackLanguageChoice?,

View file

@ -9,7 +9,6 @@ import androidx.room.Update
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import kotlinx.coroutines.flow.Flow
import java.util.UUID import java.util.UUID
@Dao @Dao
@ -57,12 +56,6 @@ interface JellyfinServerDao {
userId: UUID, userId: UUID,
): JellyfinUser? ): JellyfinUser?
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
fun getUserFlow(
serverId: UUID,
userId: UUID,
): Flow<JellyfinUser?>
@Query("DELETE FROM servers WHERE id = :serverId") @Query("DELETE FROM servers WHERE id = :serverId")
fun deleteServer(serverId: UUID) fun deleteServer(serverId: UUID)

View file

@ -1,22 +0,0 @@
package com.github.damontecres.wholphin.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.damontecres.wholphin.data.model.RememberedTab
@Dao
interface RememberedTabDao {
@Query("SELECT * from RememberedTab WHERE userId=:userId AND itemId=:itemId")
suspend fun getRememberedTab(
userId: Int,
itemId: String,
): RememberedTab?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(item: RememberedTab): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveAll(items: List<RememberedTab>)
}

View file

@ -9,17 +9,13 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
@ -60,18 +56,7 @@ class ServerRepository
val currentServer: JellyfinServer? get() = _current.value?.server val currentServer: JellyfinServer? get() = _current.value?.server
val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server } val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server }
val currentUser: JellyfinUser? get() = _current.value?.user val currentUser: JellyfinUser? get() = _current.value?.user
val currentUserFlow: Flow<JellyfinUser?> get() = _current.map { it?.user }
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserFlow: Flow<JellyfinUser?>
get() =
_current
.flatMapLatest { user ->
if (user?.user != null) {
serverDao.getUserFlow(user.user.serverId, user.user.id)
} else {
flow { emit(null) }
}
}
/** /**
* Adds a server to the app database and updated the [ApiClient] to the server's URL * Adds a server to the app database and updated the [ApiClient] to the server's URL
@ -124,7 +109,7 @@ class ServerRepository
}.build() }.build()
} }
val currentUser = CurrentUser(updatedServer, updatedUser) val currentUser = CurrentUser(updatedServer, updatedUser)
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
_current.value = currentUser _current.value = currentUser
_currentUserDto.value = userDto _currentUserDto.value = userDto
} }
@ -237,7 +222,7 @@ class ServerRepository
suspend fun removeUser(user: JellyfinUser) { suspend fun removeUser(user: JellyfinUser) {
if (current.value?.user?.id == user.id) { if (current.value?.user?.id == user.id) {
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
_current.value = null _current.value = null
} }
userPreferencesDataStore.updateData { userPreferencesDataStore.updateData {
@ -256,7 +241,7 @@ class ServerRepository
suspend fun removeServer(server: JellyfinServer) { suspend fun removeServer(server: JellyfinServer) {
if (current.value?.server?.id == server.id) { if (current.value?.server?.id == server.id) {
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
_current.value = null _current.value = null
} }
userPreferencesDataStore.updateData { userPreferencesDataStore.updateData {
@ -313,16 +298,6 @@ class ServerRepository
response.content response.content
} }
/**
* Update [currentUserDto] by querying the server
*/
suspend fun updateUserDto() {
val userDto by apiClient.userApi.getCurrentUser()
_currentUserDto.update {
if (it?.id == userDto.id && currentUser?.id == userDto.id) userDto else it
}
}
companion object { companion object {
fun getServerSharedPreferences(context: Context): SharedPreferences = fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences( context.getSharedPreferences(

View file

@ -1,287 +0,0 @@
package com.github.damontecres.wholphin.data.filter
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.SearchApi
import com.github.damontecres.wholphin.api.seerr.SearchApi.CertificationModeDiscoverTvGet
import com.github.damontecres.wholphin.api.seerr.model.DiscoverMoviesGet200Response
import com.github.damontecres.wholphin.api.seerr.model.DiscoverTvGet200Response
import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus
import com.github.damontecres.wholphin.ui.data.flip
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.SortOrder
import java.time.format.DateTimeFormatter
import kotlin.time.Duration
private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-M-d")
enum class DiscoverSort(
val key: String,
@param:StringRes val stringRes: Int,
val tvKey: String = key,
) {
POPULARITY("popularity", R.string.popularity),
RELEASE_DATE("release_date", R.string.sort_by_date_released, "first_air_date"),
TMDB_VOTE("vote_average", R.string.community_rating),
ALPHABETICAL("original_title", R.string.sort_by_name),
}
private val SortOrder.key get() =
when (this) {
SortOrder.ASCENDING -> "asc"
SortOrder.DESCENDING -> "desc"
}
@Serializable
data class DiscoverSortAndDirection(
val sort: DiscoverSort = DiscoverSort.POPULARITY,
val direction: SortOrder = SortOrder.DESCENDING,
) {
fun key(isTv: Boolean) = if (isTv) "${sort.tvKey}.${direction.key}" else "${sort.key}.${direction.key}"
fun flip() = copy(direction = direction.flip())
}
@Serializable
data class DiscoverFilter(
// /api/v1/languages
val language: String? = null,
val genreIds: List<Int>? = null,
val networkIds: List<Int>? = null,
// /api/v1/search/keyword
val keywordIds: List<Int>? = null,
val excludeKeywordIds: List<Int>? = null,
val sortBy: DiscoverSortAndDirection? = null,
// val firstAirDateGte: LocalDate? = null,
// val firstAirDateLte: LocalDate? = null,
val withRuntimeGte: Duration? = null,
val withRuntimeLte: Duration? = null,
val voteAverageGte: Int? = null,
val voteAverageLte: Int? = null,
val voteCountGte: Int? = null,
val voteCountLte: Int? = null,
// /api/v1/watchproviders/regions
val watchRegion: String? = null, // US
// networkIds?
// val watchProviders: String? = null,
val status: List<TvShowStatus>? = null,
val certification: List<String>? = null,
// val certificationGte: String? = null,
// val certificationLte: String? = null,
// val certificationCountry: String? = null, // US
// val certificationMatchExact: Boolean? = null,
) {
/**
* Returns how many of filters are actually being used in this
*/
fun countFilters(filterOptions: List<DiscoverFilterBy<*>>): Int {
var count = 0
filterOptions.forEach {
if (it.get(this) != null) count++
}
return count
}
/**
* Clear all the values for the given filters
*/
fun delete(filterOptions: List<DiscoverFilterBy<*>>): DiscoverFilter {
var newFilter = this
filterOptions.forEach {
newFilter = it.set(null, newFilter)
}
return newFilter
}
suspend fun discoverTv(
searchApi: SearchApi,
page: Int,
): DiscoverTvGet200Response =
searchApi.discoverTvGet(
page = page,
language = language,
genre = genreIds?.joinToString(",") { it.toString() },
network = null,
keywords = keywordIds?.joinToString(",") { it.toString() },
excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() },
sortBy = sortBy?.key(true),
// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) },
// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) },
withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(),
withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(),
voteAverageGte = voteAverageGte,
voteAverageLte = voteAverageLte,
voteCountGte = voteCountGte,
voteCountLte = voteCountLte,
// TODO
// watchRegion = watchRegion,
watchRegion = networkIds?.let { "US" },
watchProviders = networkIds?.joinToString(",") { it.toString() },
status = status?.joinToString("|") { it.ordinal.toString() },
certification = certification?.joinToString("|"),
// certificationGte = certificationGte,
// certificationLte = certificationLte,
certificationCountry = certification?.let { "US" },
certificationMode = certification?.let { CertificationModeDiscoverTvGet.EXACT },
)
suspend fun discoverMovies(
searchApi: SearchApi,
page: Int,
): DiscoverMoviesGet200Response =
searchApi.discoverMoviesGet(
page = page,
language = language,
genre = genreIds?.joinToString(",") { it.toString() },
// network = null,
keywords = keywordIds?.joinToString(",") { it.toString() },
excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() },
sortBy = sortBy?.key(false),
// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) },
// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) },
withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(),
withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(),
voteAverageGte = voteAverageGte,
voteAverageLte = voteAverageLte,
voteCountGte = voteCountGte,
voteCountLte = voteCountLte,
// TODO
// watchRegion = watchRegion,
watchRegion = networkIds?.let { "US" },
watchProviders = networkIds?.joinToString(",") { it.toString() },
// status = status,
certification = certification?.joinToString("|"),
// certificationGte = certificationGte,
// certificationLte = certificationLte,
certificationCountry = certification?.let { "US" },
certificationMode = certification?.let { SearchApi.CertificationModeDiscoverMoviesGet.EXACT },
)
}
val discoverMovieFilters =
listOf(
DiscoverMovieGenreFilter,
DiscoverMovieStudiosFilter,
DiscoverRatingFilter,
DiscoverMovieContentRatingFilter,
)
val discoverTvFilters =
listOf(
DiscoverTvGenreFilter,
DiscoverTvStudiosFilter,
DiscoverRatingFilter,
DiscoverTvContentRatingFilter,
DiscoverTvStatusFilter,
)
/**
* A way to filter discover
*
* Gets and sets values within a [DiscoverFilter]
*/
sealed interface DiscoverFilterBy<T> : FilterBy<DiscoverFilter, T>
data object DiscoverMovieGenreFilter : DiscoverFilterBy<List<Int>> {
override val stringRes: Int = R.string.genres
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<Int>? = filter.genreIds
override fun set(
value: List<Int>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(genreIds = value)
}
data object DiscoverTvGenreFilter : DiscoverFilterBy<List<Int>> {
override val stringRes: Int = R.string.genres
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<Int>? = filter.genreIds
override fun set(
value: List<Int>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(genreIds = value)
}
data object DiscoverMovieStudiosFilter : DiscoverFilterBy<List<Int>> {
override val stringRes: Int = R.string.studios
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<Int>? = filter.networkIds
override fun set(
value: List<Int>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(networkIds = value)
}
data object DiscoverTvStudiosFilter : DiscoverFilterBy<List<Int>> {
override val stringRes: Int = R.string.studios
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<Int>? = filter.networkIds
override fun set(
value: List<Int>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(networkIds = value)
}
data object DiscoverTvStatusFilter : DiscoverFilterBy<List<TvShowStatus>> {
override val stringRes: Int = R.string.production_status
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<TvShowStatus>? = filter.status
override fun set(
value: List<TvShowStatus>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(status = value)
}
data object DiscoverMovieContentRatingFilter : DiscoverFilterBy<List<String>> {
override val stringRes: Int = R.string.official_rating
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<String>? = filter.certification
override fun set(
value: List<String>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(certification = value)
}
data object DiscoverTvContentRatingFilter : DiscoverFilterBy<List<String>> {
override val stringRes: Int = R.string.official_rating
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<String>? = filter.certification
override fun set(
value: List<String>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(certification = value)
}
data object DiscoverRatingFilter : DiscoverFilterBy<Int> {
override val stringRes: Int = R.string.community_rating
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): Int? = filter.voteAverageGte
override fun set(
value: Int?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(voteAverageGte = value)
}

View file

@ -77,29 +77,24 @@ val DefaultPlaylistItemsOptions =
) )
/** /**
* Abstract way to filter data * A way to filter libraries
*
* Gets and sets values within a [GetItemsFilter]
*/ */
sealed interface FilterBy<FilterType, DataType> { sealed interface ItemFilterBy<T> {
@get:StringRes @get:StringRes
val stringRes: Int val stringRes: Int
val supportMultiple: Boolean val supportMultiple: Boolean
fun get(filter: FilterType): DataType? fun get(filter: GetItemsFilter): T?
fun set( fun set(
value: DataType?, value: T?,
filter: FilterType, filter: GetItemsFilter,
): FilterType ): GetItemsFilter
} }
/**
* A way to filter jellyfin libraries
*
* Gets and sets values within a [GetItemsFilter]
*/
sealed interface ItemFilterBy<T> : FilterBy<GetItemsFilter, T>
data object GenreFilter : ItemFilterBy<List<UUID>> { data object GenreFilter : ItemFilterBy<List<UUID>> {
override val stringRes: Int = R.string.genres override val stringRes: Int = R.string.genres

View file

@ -1,27 +0,0 @@
package com.github.damontecres.wholphin.data.filter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
interface SearchFilter<T : SearchFilter<T>> {
/**
* Returns how many of filters are actually being used in this [GetItemsFilter]
*/
fun countFilters(filterOptions: List<FilterBy<T, *>>): Int {
var count = 0
filterOptions.forEach {
if (it.get(this as T) != null) count++
}
return count
}
/**
* Clear all the values for the given filters
*/
fun delete(filterOptions: List<FilterBy<T, *>>): T {
var newFilter = this
filterOptions.forEach {
newFilter = it.set(null, newFilter as T)
}
return newFilter as T
}
}

View file

@ -5,18 +5,13 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.music.artistsString import com.github.damontecres.wholphin.ui.detail.music.artistsString
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.formatEpisodeNumber
import com.github.damontecres.wholphin.ui.getDateFormatter import com.github.damontecres.wholphin.ui.getDateFormatter
import com.github.damontecres.wholphin.ui.joinNotBlank
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
@ -24,13 +19,11 @@ import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.seasonEpisodePadded import com.github.damontecres.wholphin.ui.seasonEpisodePadded
import com.github.damontecres.wholphin.ui.seriesProductionYears import com.github.damontecres.wholphin.ui.seriesProductionYears
import com.github.damontecres.wholphin.ui.timeRemaining import com.github.damontecres.wholphin.ui.timeRemaining
import com.github.damontecres.wholphin.ui.toServerString
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import java.util.Locale import java.util.Locale
import java.util.UUID import java.util.UUID
@ -55,7 +48,7 @@ data class BaseItem(
get() = type.playable get() = type.playable
override val sortName: String override val sortName: String
get() = data.alphabetSortName get() = data.sortName ?: data.name ?: ""
val type get() = data.type val type get() = data.type
@ -63,14 +56,14 @@ data class BaseItem(
val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name
val subtitle: String? by lazy { val subtitle
get() =
when (type) { when (type) {
BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ") BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
BaseItemKind.SERIES -> data.seriesProductionYears BaseItemKind.SERIES -> data.seriesProductionYears
BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ") BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ")
else -> data.productionYear?.toString() else -> data.productionYear?.toString()
} }
}
val subtitleLong: String? by lazy { val subtitleLong: String? by lazy {
if (type == BaseItemKind.EPISODE) { if (type == BaseItemKind.EPISODE) {
@ -78,7 +71,7 @@ data class BaseItem(
add(data.seasonEpisodePadded) add(data.seasonEpisodePadded)
add(data.name) add(data.name)
add(data.premiereDate?.let { formatDateTime(it) }) add(data.premiereDate?.let { formatDateTime(it) })
}.joinNotBlank(" - ") }.filterNotNull().joinToString(" - ")
} else { } else {
data.productionYear?.toString() data.productionYear?.toString()
} }
@ -98,12 +91,7 @@ data class BaseItem(
val favorite get() = data.userData?.isFavorite ?: false val favorite get() = data.userData?.isFavorite ?: false
val timeRemainingOrRuntime: Duration? val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks
get() =
when (type) {
BaseItemKind.PROGRAM -> null
else -> data.timeRemaining ?: data.runTimeTicks?.ticks
}
/** /**
* Contains pre computed UI elements that would be expensive to create on the main thread * Contains pre computed UI elements that would be expensive to create on the main thread
@ -112,7 +100,7 @@ data class BaseItem(
val ui = val ui =
BaseItemUi( BaseItemUi(
episodeCornerText = episodeCornerText =
data.indexNumber?.let { formatEpisodeNumber(it) } data.indexNumber?.let { "E$it" }
?: data.premiereDate?.let(::formatDateTime), ?: data.premiereDate?.let(::formatDateTime),
episodeUnplayedCornerText = episodeUnplayedCornerText =
if (type == BaseItemKind.SERIES || if (type == BaseItemKind.SERIES ||
@ -120,7 +108,7 @@ data class BaseItem(
type == BaseItemKind.EPISODE || type == BaseItemKind.EPISODE ||
type == BaseItemKind.BOX_SET type == BaseItemKind.BOX_SET
) { ) {
data.indexNumber?.let { formatEpisodeNumber(it) } data.indexNumber?.let { "E$it" }
?: data.userData ?: data.userData
?.unplayedItemCount ?.unplayedItemCount
?.takeIf { it > 0 } ?.takeIf { it > 0 }
@ -148,36 +136,16 @@ data class BaseItem(
} else if (type == BaseItemKind.BOX_SET) { } else if (type == BaseItemKind.BOX_SET) {
data.productionYear?.let { add(it.toString()) } data.productionYear?.let { add(it.toString()) }
data.childCount?.let { add("$it items") } data.childCount?.let { add("$it items") }
} else if (type == BaseItemKind.PROGRAM) {
data.channelName?.let(::add)
if (data.isSeries == true) {
// TV episode
data.seasonEpisode?.let(::add)
data.premiereDate?.let {
add(getDateFormatter().format(it))
}
} else {
data.productionYear?.let { add(it.toString()) }
}
} else { } else {
data.productionYear?.let { add(it.toString()) } data.productionYear?.let { add(it.toString()) }
} }
data.runTimeTicks data.runTimeTicks
?.ticks ?.ticks
?.roundMinutes ?.roundMinutes
?.takeIf { it > Duration.ZERO } ?.let { add(it.toString()) }
?.let { add(WholphinApplication.instance.resources.formatDuration(it)) }
data.timeRemaining data.timeRemaining
?.roundMinutes ?.roundMinutes
?.let { ?.let { add("$it left") }
val resources = WholphinApplication.instance.resources
add(
resources.getString(
R.string.time_left,
resources.formatDuration(it),
),
)
}
} }
details.forEachIndexed { index, string -> details.forEachIndexed { index, string ->
append(string) append(string)
@ -229,38 +197,25 @@ data class BaseItem(
*/ */
fun destination(index: Int? = null): Destination { fun destination(index: Int? = null): Destination {
if (destinationOverride != null) return destinationOverride if (destinationOverride != null) return destinationOverride
if (data.extraType != null || type == BaseItemKind.TRAILER) {
// Extras including trailers should always play directly
return Destination.Playback(id, 0)
}
val result = val result =
// Redirect episodes & seasons to their series if possible // Redirect episodes & seasons to their series if possible
when (type) { when (type) {
BaseItemKind.EPISODE -> { BaseItemKind.EPISODE -> {
val seasonId = data.seasonId data.seasonId?.let { seasonId ->
val seriesId = data.seriesId
if (seasonId != null && seriesId != null) {
Destination.SeriesOverview( Destination.SeriesOverview(
seriesId, data.seriesId!!,
BaseItemKind.SERIES, BaseItemKind.SERIES,
SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber), SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber),
) )
} else { } ?: Destination.MediaItem(this)
Destination.MediaItem(this)
}
} }
BaseItemKind.SEASON -> { BaseItemKind.SEASON -> {
val seriesId = data.seriesId
if (seriesId != null) {
Destination.SeriesOverview( Destination.SeriesOverview(
seriesId, data.seriesId!!,
BaseItemKind.SERIES, BaseItemKind.SERIES,
SeasonEpisodeIds(id, indexNumber, null, null), SeasonEpisodeIds(id, indexNumber, null, null),
) )
} else {
Destination.MediaItem(this)
}
} }
BaseItemKind.TV_CHANNEL -> { BaseItemKind.TV_CHANNEL -> {
@ -282,16 +237,6 @@ data class BaseItem(
} }
} }
BaseItemKind.AUDIO -> {
data.albumId?.let { albumId ->
Destination.MediaItem(
itemId = albumId,
type = BaseItemKind.MUSIC_ALBUM,
initialSongId = id,
)
} ?: Destination.MediaItem(this)
}
else -> { else -> {
Destination.MediaItem(this) Destination.MediaItem(this)
} }
@ -315,9 +260,6 @@ data class BaseItem(
val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } } val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } }
/** The server strips leading articles (eg "The ") from [BaseItemDto.sortName]. */
val BaseItemDto.alphabetSortName: String get() = sortName ?: name ?: ""
@Immutable @Immutable
data class BaseItemUi( data class BaseItemUi(
val episodeCornerText: String?, val episodeCornerText: String?,
@ -341,7 +283,6 @@ fun createGenreDestination(
parentId: UUID, parentId: UUID,
parentName: String?, parentName: String?,
includeItemTypes: List<BaseItemKind>?, includeItemTypes: List<BaseItemKind>?,
collectionType: CollectionType,
) = Destination.FilteredCollection( ) = Destination.FilteredCollection(
itemId = parentId, itemId = parentId,
parentType = BaseItemKind.GENRE, parentType = BaseItemKind.GENRE,
@ -357,11 +298,9 @@ fun createGenreDestination(
genres = listOf(genreId), genres = listOf(genreId),
includeItemTypes = includeItemTypes, includeItemTypes = includeItemTypes,
), ),
useSavedLibraryDisplayInfo = true, useSavedLibraryDisplayInfo = false,
libraryDisplayInfoIdOverride = "${parentId.toServerString()}_genres",
), ),
recursive = true, recursive = true,
collectionType = collectionType,
) )
fun createStudioDestination( fun createStudioDestination(
@ -385,11 +324,9 @@ fun createStudioDestination(
studios = listOf(studioId), studios = listOf(studioId),
includeItemTypes = includeItemTypes, includeItemTypes = includeItemTypes,
), ),
useSavedLibraryDisplayInfo = true, useSavedLibraryDisplayInfo = false,
libraryDisplayInfoIdOverride = "${parentId.toServerString()}_studios",
), ),
recursive = true, recursive = true,
collectionType = CollectionType.UNKNOWN,
) )
val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty() val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty()

View file

@ -1,30 +1,38 @@
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
/** /**
* Represents a chapter within a video * Represents a chapter within a video
*/ */
data class Chapter( data class Chapter(
val itemId: UUID,
val name: String?, val name: String?,
val position: Duration, val position: Duration,
val tag: String?, val imageUrl: String?,
val index: Int,
) { ) {
companion object { companion object {
fun fromDto(dto: BaseItemDto): List<Chapter> = fun fromDto(
dto: BaseItemDto,
api: ApiClient,
): List<Chapter> =
dto.chapters dto.chapters
?.mapIndexed { index, chapter -> ?.mapIndexed { index, chapter ->
Chapter( Chapter(
chapter.name,
chapter.startPositionTicks.ticks,
chapter.imageTag?.let {
api.imageApi.getItemImageUrl(
itemId = dto.id, itemId = dto.id,
name = chapter.name, imageType = ImageType.CHAPTER,
position = chapter.startPositionTicks.ticks, tag = it,
tag = chapter.imageTag, imageIndex = index,
index = index, )
},
) )
}?.sortedBy { it.position } }?.sortedBy { it.position }
.orEmpty() .orEmpty()

View file

@ -56,14 +56,12 @@ enum class SeerrItemType(
enum class SeerrAvailability( enum class SeerrAvailability(
val status: Int, val status: Int,
) { ) {
// https://github.com/seerr-team/seerr/blob/develop/server/constants/media.ts#L14
UNKNOWN(1), UNKNOWN(1),
PENDING(2), PENDING(2),
PROCESSING(3), PROCESSING(3),
PARTIALLY_AVAILABLE(4), PARTIALLY_AVAILABLE(4),
AVAILABLE(5), AVAILABLE(5),
BLOCKLISTED(6), DELETED(6),
DELETED(7),
; ;
companion object { companion object {
@ -71,23 +69,6 @@ enum class SeerrAvailability(
} }
} }
@Serializable
enum class RequestStatus(
val status: Int,
) {
UNKNOWN(0),
PENDING(1),
APPROVED(2),
DECLINED(3),
FAILURE(4),
COMPLETED(5),
;
companion object {
fun from(status: Int?) = RequestStatus.entries.firstOrNull { it.status == status } ?: UNKNOWN
}
}
/** /**
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability]. * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability].
*/ */
@ -103,7 +84,6 @@ data class DiscoverItem(
@Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?, @Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?,
val posterUrl: String?, val posterUrl: String?,
val backDropUrl: String?, val backDropUrl: String?,
val logoUrl: String?,
val jellyfinItemId: UUID?, val jellyfinItemId: UUID?,
) : CardGridItem { ) : CardGridItem {
override val gridId: String get() = id.toString() override val gridId: String get() = id.toString()
@ -112,7 +92,13 @@ data class DiscoverItem(
val destination: Destination val destination: Destination
get() { get() {
val jfType = type.baseItemKind val jfType =
when (type) {
SeerrItemType.MOVIE -> BaseItemKind.MOVIE
SeerrItemType.TV -> BaseItemKind.SERIES
SeerrItemType.PERSON -> BaseItemKind.PERSON
SeerrItemType.UNKNOWN -> null
}
return if (jellyfinItemId != null && jfType != null) { return if (jellyfinItemId != null && jfType != null) {
Destination.MediaItem( Destination.MediaItem(
itemId = jellyfinItemId, itemId = jellyfinItemId,

View file

@ -9,7 +9,6 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.VideoType import org.jellyfin.sdk.model.api.VideoType
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
@ -23,13 +22,9 @@ data class CollectionFolderFilter(
val nameOverride: String? = null, val nameOverride: String? = null,
val filter: GetItemsFilter = GetItemsFilter(), val filter: GetItemsFilter = GetItemsFilter(),
/** /**
* Whether to use the library's saved sort & filter * Whether to use the libray's saved sort & filter
*/ */
val useSavedLibraryDisplayInfo: Boolean = true, val useSavedLibraryDisplayInfo: Boolean = true,
/**
* Use a different ID for the library's saved sort & filter, such as for library's genres
*/
val libraryDisplayInfoIdOverride: String? = null,
) )
/** /**
@ -139,22 +134,6 @@ data class GetItemsFilter(
isFavorite = favorite, isFavorite = favorite,
) )
fun applyTo(req: GetArtistsRequest) =
req.copy(
minCommunityRating = minCommunityRating,
isFavorite = favorite,
genreIds = genres,
personIds = persons,
studioIds = studios,
tags = tags,
officialRatings = officialRatings,
years =
buildSet {
years?.letNotEmpty(::addAll)
decades?.forEach { addAll(it..<(it + 10)) }
},
)
/** /**
* Merge another [GetItemsFilter] onto this one, replacing only unset values * Merge another [GetItemsFilter] onto this one, replacing only unset values
*/ */
@ -179,5 +158,4 @@ data class GetItemsFilter(
enum class GetItemsFilterOverride { enum class GetItemsFilterOverride {
NONE, NONE,
PERSON, PERSON,
ARTIST,
} }

View file

@ -109,7 +109,15 @@ sealed interface HomeRowConfig {
@SerialName("Favorite") @SerialName("Favorite")
data class Favorite( data class Favorite(
val kind: BaseItemKind, val kind: BaseItemKind,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), override val viewOptions: HomeRowViewOptions =
if (kind == BaseItemKind.EPISODE) {
HomeRowViewOptions(
heightDp = Cards.HEIGHT_EPISODE,
aspectRatio = AspectRatio.WIDE,
)
} else {
HomeRowViewOptions()
},
) : HomeRowConfig { ) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions) override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions)
} }
@ -235,7 +243,7 @@ data class HomeRowViewOptions(
val liveTvDefault = val liveTvDefault =
HomeRowViewOptions( HomeRowViewOptions(
heightDp = Cards.HEIGHT_LIVE_TV, heightDp = 96,
aspectRatio = AspectRatio.WIDE, aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.FIT, contentScale = PrefContentScale.FIT,
) )

View file

@ -11,8 +11,6 @@ import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.Relation import androidx.room.Relation
import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
import com.github.damontecres.wholphin.preferences.UserProfileSettings
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
@ -67,17 +65,11 @@ data class JellyfinUser(
val requireLogin: Boolean = false, val requireLogin: Boolean = false,
val lastUsed: ZonedDateTime? = null, val lastUsed: ZonedDateTime? = null,
val uiLanguage: String? = null, val uiLanguage: String? = null,
@Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(),
) { ) {
val hasPin: Boolean get() = pin.isNotNullOrBlank() val hasPin: Boolean get() = pin.isNotNullOrBlank()
val isProtected: Boolean get() = hasPin || requireLogin
override fun toString(): String = override fun toString(): String =
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " + "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})"
"accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " +
"requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " +
"appPreferences=$appPreferences"
} }
/** /**
@ -91,13 +83,3 @@ data class JellyfinServerUsers(
) )
val users: List<JellyfinUser>, val users: List<JellyfinUser>,
) )
@Serializable
data class JellyfinUserPreferences(
@ColumnInfo(defaultValue = "")
val preferredAudioLanguage: String = UserProfileSettings.USE_USER_PROFILE,
@ColumnInfo(defaultValue = "")
val preferredSubtitleLanguage: String = UserProfileSettings.USE_USER_PROFILE,
@ColumnInfo(defaultValue = "USE_USER_PROFILE")
val subtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE,
)

View file

@ -1,5 +1,8 @@
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID import java.util.UUID
@ -8,13 +11,38 @@ import java.util.UUID
* *
* This is not the same thing as a Jellyfin server playlist * This is not the same thing as a Jellyfin server playlist
*/ */
data class Playlist( class Playlist(
val items: List<PlaylistItem>, items: List<PlaylistItem>,
startIndex: Int = 0,
) { ) {
val items = items.subList(startIndex, items.size)
var index by mutableIntStateOf(0)
fun hasPrevious(): Boolean = index > 0
fun hasNext(): Boolean = (index + 1) < items.size
fun getPreviousAndReverse(): PlaylistItem = items[--index]
fun getAndAdvance(): PlaylistItem = items[++index]
fun peek(): PlaylistItem? = items.getOrNull(index + 1)
fun upcomingItems(): List<PlaylistItem> = items.subList(index + 1, items.size)
fun advanceTo(id: UUID): PlaylistItem? {
while (hasNext()) {
val potential = getAndAdvance()
if (potential.id == id) {
return potential
}
}
return null
}
companion object { companion object {
const val MAX_SIZE = 100 const val MAX_SIZE = 100
fun fromMedia(list: List<BaseItem>) = Playlist(list.map { PlaylistItem.Media(it) })
} }
} }

View file

@ -1,24 +0,0 @@
package com.github.damontecres.wholphin.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
foreignKeys = [
ForeignKey(
entity = JellyfinUser::class,
parentColumns = arrayOf("rowId"),
childColumns = arrayOf("userId"),
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
),
],
indices = [Index("userId", "itemId", unique = true)],
primaryKeys = ["userId", "itemId"],
)
data class RememberedTab(
val userId: Int,
val itemId: String,
val index: Int,
)

View file

@ -7,7 +7,6 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig
*/ */
enum class SeerrPermission( enum class SeerrPermission(
private val flag: Int, private val flag: Int,
vararg val parents: SeerrPermission,
) { ) {
// Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts // Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts
NONE(0), NONE(0),
@ -18,28 +17,28 @@ enum class SeerrPermission(
REQUEST(32), REQUEST(32),
VOTE(64), VOTE(64),
AUTO_APPROVE(128), AUTO_APPROVE(128),
AUTO_APPROVE_MOVIE(256, AUTO_APPROVE), AUTO_APPROVE_MOVIE(256),
AUTO_APPROVE_TV(512, AUTO_APPROVE), AUTO_APPROVE_TV(512),
REQUEST_4K(1024), REQUEST_4K(1024),
REQUEST_4K_MOVIE(2048, REQUEST_4K), REQUEST_4K_MOVIE(2048),
REQUEST_4K_TV(4096, REQUEST_4K), REQUEST_4K_TV(4096),
REQUEST_ADVANCED(8192, MANAGE_REQUESTS), REQUEST_ADVANCED(8192),
REQUEST_VIEW(16384, MANAGE_REQUESTS), REQUEST_VIEW(16384),
AUTO_APPROVE_4K(32768), AUTO_APPROVE_4K(32768),
AUTO_APPROVE_4K_MOVIE(65536, AUTO_APPROVE_4K), AUTO_APPROVE_4K_MOVIE(65536),
AUTO_APPROVE_4K_TV(131072, AUTO_APPROVE_4K), AUTO_APPROVE_4K_TV(131072),
REQUEST_MOVIE(262144, REQUEST), REQUEST_MOVIE(262144),
REQUEST_TV(524288, REQUEST), REQUEST_TV(524288),
MANAGE_ISSUES(1048576), MANAGE_ISSUES(1048576),
VIEW_ISSUES(2097152, MANAGE_ISSUES), VIEW_ISSUES(2097152),
CREATE_ISSUES(4194304, MANAGE_ISSUES), CREATE_ISSUES(4194304),
AUTO_REQUEST(8388608), AUTO_REQUEST(8388608),
AUTO_REQUEST_MOVIE(16777216, AUTO_REQUEST), AUTO_REQUEST_MOVIE(16777216),
AUTO_REQUEST_TV(33554432, AUTO_REQUEST), AUTO_REQUEST_TV(33554432),
RECENT_VIEW(67108864, MANAGE_REQUESTS), RECENT_VIEW(67108864),
WATCHLIST_VIEW(134217728, MANAGE_REQUESTS), WATCHLIST_VIEW(134217728),
MANAGE_BLOCKLIST(268435456), MANAGE_BLACKLIST(268435456),
VIEW_BLOCKLIST(1073741824, MANAGE_BLOCKLIST), VIEW_BLACKLIST(1073741824),
; ;
internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag
@ -49,8 +48,5 @@ enum class SeerrPermission(
* Check whether the user has the given permissions (or is an admin) * Check whether the user has the given permissions (or is an admin)
*/ */
fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean { fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean {
if (this?.permissions == null) return false return permission.hasPermission(this?.permissions ?: return false) || SeerrPermission.ADMIN.hasPermission(permissions)
return permission.hasPermission(permissions) ||
permission.parents.any { hasPermission(it) } ||
SeerrPermission.ADMIN.hasPermission(permissions)
} }

View file

@ -53,10 +53,7 @@ sealed interface AppPreference<Pref, T> {
value: T?, value: T?,
): String? = null ): String? = null
fun validate( fun validate(value: T): PreferenceValidation = PreferenceValidation.Valid
prefs: Pref,
value: T,
): PreferenceValidation = PreferenceValidation.Valid
companion object { companion object {
val SkipForward = val SkipForward =
@ -218,16 +215,15 @@ sealed interface AppPreference<Pref, T> {
) )
val HomeClickToPlay = val HomeClickToPlay =
AppChoicePreference<AppPreferences, Boolean>( AppSwitchPreference<AppPreferences>(
title = R.string.continue_watching_click_behavior, title = R.string.continue_watching_click_behavior,
defaultValue = false, defaultValue = false,
getter = { it.homePagePreferences.clickToPlay }, getter = { it.homePagePreferences.clickToPlay },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateHomePagePreferences { clickToPlay = value } prefs.updateHomePagePreferences { clickToPlay = value }
}, },
displayValues = R.array.home_click_to_play_options, summaryOn = R.string.continue_watching_click_summary_on,
indexToValue = { it != 0 }, summaryOff = R.string.continue_watching_click_summary_off,
valueToIndex = { if (it) 1 else 0 },
) )
val PlayThemeMusic = val PlayThemeMusic =
@ -395,25 +391,18 @@ sealed interface AppPreference<Pref, T> {
defaultValue = true, defaultValue = true,
getter = { it.playbackPreferences.overrides.ac3Supported }, getter = { it.playbackPreferences.overrides.ac3Supported },
setter = { prefs, value -> setter = { prefs, value ->
if (!value) prefs.updateExperimentalPreferences { preferAc3Surround = false } prefs.updatePlaybackOverrides { ac3Supported = value }
prefs.updatePlaybackOverrides {
ac3Supported = value
}
}, },
summaryOn = R.string.enabled, summaryOn = R.string.enabled,
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val DownMixStereo = val DownMixStereo =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.downmix_stereo, title = R.string.downmix_stereo,
defaultValue = false, defaultValue = false,
getter = { it.playbackPreferences.overrides.downmixStereo }, getter = { it.playbackPreferences.overrides.downmixStereo },
setter = { prefs, value -> setter = { prefs, value ->
if (value) prefs.updateExperimentalPreferences { preferAc3Surround = false } prefs.updatePlaybackOverrides { downmixStereo = value }
prefs.updatePlaybackOverrides {
downmixStereo = value
}
}, },
summaryOn = R.string.enabled, summaryOn = R.string.enabled,
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
@ -583,13 +572,6 @@ sealed interface AppPreference<Pref, T> {
destination = Destination.Settings(PreferenceScreenOption.ADVANCED), destination = Destination.Settings(PreferenceScreenOption.ADVANCED),
) )
val UserProfileSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.override_user_profile_settings,
destination = Destination.UserAppPreferences,
summary = R.string.override_user_profile_settings_summary,
)
val SkipIntros = val SkipIntros =
AppChoicePreference<AppPreferences, SkipSegmentBehavior>( AppChoicePreference<AppPreferences, SkipSegmentBehavior>(
title = R.string.skip_intro_behavior, title = R.string.skip_intro_behavior,
@ -662,20 +644,6 @@ sealed interface AppPreference<Pref, T> {
destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS), destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS),
) )
val DpadSeekModePref =
AppChoicePreference<AppPreferences, DpadSeekMode>(
title = R.string.d_pad_seek_mode_title,
defaultValue = DpadSeekMode.SEEKBAR_MINIMAL,
getter = { it.playbackPreferences.dpadSeekMode },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { dpadSeekMode = value }
},
displayValues = R.array.dpad_seek_mode_options,
indexToValue = { DpadSeekMode.forNumber(it) },
valueToIndex = { if (it != DpadSeekMode.UNRECOGNIZED) it.number else 0 },
subtitles = R.array.dpad_seek_mode_summaries,
)
val GlobalContentScale = val GlobalContentScale =
AppChoicePreference<AppPreferences, PrefContentScale>( AppChoicePreference<AppPreferences, PrefContentScale>(
title = R.string.global_content_scale, title = R.string.global_content_scale,
@ -1138,7 +1106,6 @@ val basicPreferences =
AppPreference.CustomizeHome, AppPreference.CustomizeHome,
AppPreference.UserPinnedNavDrawerItems, AppPreference.UserPinnedNavDrawerItems,
AppPreference.UserInterfaceLanguage, AppPreference.UserInterfaceLanguage,
AppPreference.UserProfileSettings,
), ),
), ),
PreferenceGroup( PreferenceGroup(
@ -1241,7 +1208,6 @@ val advancedPreferences =
AppPreference.CinemaMode, AppPreference.CinemaMode,
AppPreference.GlobalContentScale, AppPreference.GlobalContentScale,
AppPreference.SkipSegments, AppPreference.SkipSegments,
AppPreference.DpadSeekModePref,
AppPreference.MaxBitrate, AppPreference.MaxBitrate,
AppPreference.RefreshRateSwitching, AppPreference.RefreshRateSwitching,
AppPreference.ResolutionSwitching, AppPreference.ResolutionSwitching,
@ -1300,16 +1266,8 @@ val advancedPreferences =
AppPreference.DebugLogging, AppPreference.DebugLogging,
AppPreference.ImageDiskCacheSize, AppPreference.ImageDiskCacheSize,
AppPreference.ClearImageCache, AppPreference.ClearImageCache,
ExperimentalPreference.Enable,
AppPreference.OssLicenseInfo, AppPreference.OssLicenseInfo,
), ),
conditionalPreferences =
listOf(
ConditionalPreferences(
condition = { it.experimentalPreferences.enabled },
preferences = listOf(ExperimentalPreference.ExperimentalSettings),
),
),
), ),
) )
} }
@ -1338,14 +1296,6 @@ val screensaverPreferences =
ScreensaverPreference.Start, ScreensaverPreference.Start,
), ),
), ),
PreferenceGroup(
title = R.string.dim_screen,
preferences =
listOf(
ScreensaverPreference.DimToggle,
ScreensaverPreference.DimPercentage,
),
),
) )
data class AppSwitchPreference<Pref>( data class AppSwitchPreference<Pref>(
@ -1353,16 +1303,11 @@ data class AppSwitchPreference<Pref>(
override val defaultValue: Boolean, override val defaultValue: Boolean,
override val getter: (prefs: Pref) -> Boolean, override val getter: (prefs: Pref) -> Boolean,
override val setter: (prefs: Pref, value: Boolean) -> Pref, override val setter: (prefs: Pref, value: Boolean) -> Pref,
val validator: (prefs: Pref, value: Boolean) -> PreferenceValidation = { _, _ -> PreferenceValidation.Valid }, val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid },
@param:StringRes val summary: Int? = null, @param:StringRes val summary: Int? = null,
@param:StringRes val summaryOn: Int? = null, @param:StringRes val summaryOn: Int? = null,
@param:StringRes val summaryOff: Int? = null, @param:StringRes val summaryOff: Int? = null,
) : AppPreference<Pref, Boolean> { ) : AppPreference<Pref, Boolean> {
override fun validate(
prefs: Pref,
value: Boolean,
): PreferenceValidation = validator.invoke(prefs, value)
override fun summary( override fun summary(
context: Context, context: Context,
value: Boolean?, value: Boolean?,

View file

@ -55,7 +55,6 @@ class AppPreferencesSerializer
AppPreference.RefreshRateSwitching.defaultValue AppPreference.RefreshRateSwitching.defaultValue
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
cinemaMode = AppPreference.CinemaMode.defaultValue cinemaMode = AppPreference.CinemaMode.defaultValue
dpadSeekMode = AppPreference.DpadSeekModePref.defaultValue
overrides = overrides =
PlaybackOverrides PlaybackOverrides
@ -175,16 +174,6 @@ class AppPreferencesSerializer
showLyrics = true showLyrics = true
showAlbumArt = true showAlbumArt = true
}.build() }.build()
experimentalPreferences =
ExperimentalPreferences
.newBuilder()
.apply {
enabled = false
videoTunnelingEnabled = false
preferAc3Surround = ExperimentalPreference.PreferAc3ForSurround.defaultValue
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
}.build()
}.build() }.build()
override suspend fun readFrom(input: InputStream): AppPreferences { override suspend fun readFrom(input: InputStream): AppPreferences {
@ -263,11 +252,6 @@ inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder
musicPreferences = musicPreferences.toBuilder().apply(block).build() musicPreferences = musicPreferences.toBuilder().apply(block).build()
} }
inline fun AppPreferences.updateExperimentalPreferences(block: ExperimentalPreferences.Builder.() -> Unit): AppPreferences =
update {
experimentalPreferences = experimentalPreferences.toBuilder().apply(block).build()
}
fun SubtitlePreferences.Builder.resetSubtitles() { fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()

View file

@ -1,96 +0,0 @@
package com.github.damontecres.wholphin.preferences
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
object ExperimentalPreference {
val Enable =
AppSwitchPreference<AppPreferences>(
title = R.string.experimental_settings,
defaultValue = false,
getter = { it.experimentalPreferences.enabled },
setter = { prefs, value ->
prefs.updateExperimentalPreferences { enabled = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val ExperimentalSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.experimental_settings,
destination = Destination.Settings(PreferenceScreenOption.EXPERIMENTAL),
)
val VideoTunneling =
AppSwitchPreference<AppPreferences>(
title = R.string.video_tunneling,
defaultValue = false,
getter = { it.experimentalPreferences.videoTunnelingEnabled },
setter = { prefs, value ->
prefs.updateExperimentalPreferences { videoTunnelingEnabled = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val PreferAc3ForSurround =
AppSwitchPreference<AppPreferences>(
title = R.string.prefer_ac3_for_surround,
defaultValue = false,
getter = { it.experimentalPreferences.preferAc3Surround },
setter = { prefs, value ->
prefs.updateExperimentalPreferences {
preferAc3Surround = value
}
},
summary = R.string.prefer_ac3_for_surround_summary,
validator = { prefs, value ->
prefs.playbackPreferences.overrides.let {
if (value && !it.ac3Supported) {
PreferenceValidation.Invalid("AC3 support is not enabled")
} else if (value && it.downmixStereo) {
PreferenceValidation.Invalid("Always downmixing to stereo")
} else {
PreferenceValidation.Valid
}
}
},
)
val DisableAudioOffload =
AppSwitchPreference<AppPreferences>(
title = R.string.disable_audio_offload,
defaultValue = false,
getter = { it.experimentalPreferences.disableAudioOffload },
setter = { prefs, value ->
prefs.updateExperimentalPreferences { disableAudioOffload = value }
},
summary = R.string.disable_audio_offload_summary,
)
}
val experimentalPreferences =
buildList {
add(
PreferenceGroup(
title = R.string.experimental_settings,
preferences =
listOf(
ExperimentalPreference.VideoTunneling,
ExperimentalPreference.PreferAc3ForSurround,
ExperimentalPreference.DisableAudioOffload,
),
),
)
}
/**
* Get a value from [ExperimentalPreference] or null if not enabled
*/
fun <T> ExperimentalPreferences.get(block: ExperimentalPreferences.() -> T): T? = if (enabled) block.invoke(this) else null
fun ExperimentalPreferences.enabled(block: ExperimentalPreferences.() -> Boolean): Boolean = enabled && block.invoke(this)

View file

@ -1,19 +1,5 @@
package com.github.damontecres.wholphin.preferences package com.github.damontecres.wholphin.preferences
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.isDown
import com.github.damontecres.wholphin.ui.playback.isUp
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.launch
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -24,48 +10,3 @@ val PlaybackPreferences.skipBackOnResume: Duration?
} else { } else {
null null
} }
/**
* Adds modifiers so that pressing Up on the first element scrolls to the bottom and vice versa
*/
@Composable
fun Modifier.lazyListWrapScrolling(
state: LazyListState,
focused: Boolean,
isFirst: Boolean,
isLast: Boolean,
firstFocusRequester: FocusRequester,
lastFocusRequester: FocusRequester,
): Modifier {
val scope = rememberCoroutineScope()
return this
.ifElse(
isFirst,
Modifier
.focusRequester(firstFocusRequester)
.onKeyEvent {
if (focused && isUp(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
state.animateScrollToItem(state.layoutInfo.totalItemsCount - 1)
lastFocusRequester.tryRequestFocus()
}
return@onKeyEvent true
}
false
},
).ifElse(
isLast,
Modifier
.focusRequester(lastFocusRequester)
.onKeyEvent {
if (focused && isDown(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
state.animateScrollToItem(0)
firstFocusRequester.tryRequestFocus()
}
return@onKeyEvent true
}
false
},
)
}

View file

@ -176,37 +176,6 @@ object ScreensaverPreference {
}, },
) )
val DimToggle =
AppSwitchPreference<AppPreferences>(
title = R.string.dim_after_delay,
defaultValue = AppPreference.ShowClock.defaultValue,
getter = { it.interfacePreferences.screensaverPreference.dimEnabled },
setter = { prefs, value ->
prefs.updateScreensaverPreferences { dimEnabled = value }
},
summaryOn = R.string.yes,
summaryOff = R.string.no,
)
val DimPercentage =
AppSliderPreference<AppPreferences>(
title = R.string.dim_percentage,
defaultValue = 75,
min = 0,
max = 95,
interval = 5,
getter = {
it.interfacePreferences.screensaverPreference.dimPercent
.toLong()
},
setter = { prefs, value ->
prefs.updateScreensaverPreferences { dimPercent = value.toInt() }
},
summarizer = { value ->
value?.let { "$value%" }
},
)
val Start = val Start =
AppClickablePreference<AppPreferences>( AppClickablePreference<AppPreferences>(
title = R.string.start_screensaver, title = R.string.start_screensaver,

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.preferences package com.github.damontecres.wholphin.preferences
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.UserConfiguration import org.jellyfin.sdk.model.api.UserConfiguration
@ -9,7 +8,6 @@ import org.jellyfin.sdk.model.api.UserConfiguration
*/ */
data class UserPreferences( data class UserPreferences(
val appPreferences: AppPreferences, val appPreferences: AppPreferences,
val userPreferences: JellyfinUserPreferences?,
) )
val DefaultUserConfiguration = val DefaultUserConfiguration =

View file

@ -1,70 +0,0 @@
package com.github.damontecres.wholphin.preferences
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
/**
* Represents settings from the user's profile on the server that may be overridden within the app
*/
object UserProfileSettings {
/**
* Special value that means the preferred language should be taken from the user's profile on the server
*/
const val USE_USER_PROFILE = ""
/**
* Special value that means the user has no preferred language
*/
const val PREFER_ANY_LANGUAGE = "_any-language"
val PreferredAudioLang =
AppClickablePreference<JellyfinUserPreferences>(
title = R.string.preferred_audio_language,
getter = { },
setter = { prefs, _ -> prefs },
)
val PreferredSubtitleLang =
AppClickablePreference<JellyfinUserPreferences>(
title = R.string.preferred_subtitle_language,
summary = null,
getter = { },
setter = { prefs, _ -> prefs },
)
val SubtitleModePref =
AppChoicePreference<JellyfinUserPreferences, SubtitleModePreference>(
title = R.string.subtitle_mode,
defaultValue = SubtitleModePreference.USE_USER_PROFILE,
getter = { it.subtitleMode },
setter = { prefs, value ->
prefs.copy(subtitleMode = value)
},
displayValues = R.array.subtitle_mode_options,
indexToValue = { SubtitleModePreference.entries[it] },
valueToIndex = { it.ordinal },
)
val Preferences =
listOf(
PreferenceGroup(
title = R.string.profile_specific_settings_from_server,
preferences =
listOf(
PreferredAudioLang,
PreferredSubtitleLang,
SubtitleModePref,
),
),
)
}
enum class SubtitleModePreference {
USE_USER_PROFILE,
DEFAULT,
SMART,
ONLY_FORCED,
ALWAYS,
NONE,
}

View file

@ -6,18 +6,12 @@ import android.os.Build
import androidx.core.content.edit import androidx.core.content.edit
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.RememberedTab
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.ScreensaverPreference
import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateExperimentalPreferences
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
@ -31,16 +25,12 @@ import com.github.damontecres.wholphin.preferences.updateSearchPreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.preferences.subtitle.shouldEnableSeparateHdrToggle
import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
import com.github.damontecres.wholphin.util.Version import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -54,8 +44,6 @@ class AppUpgradeHandler
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val appPreferences: DataStore<AppPreferences>, private val appPreferences: DataStore<AppPreferences>,
private val seerrServerDao: SeerrServerDao, private val seerrServerDao: SeerrServerDao,
private val serverDao: JellyfinServerDao,
private val rememberedTabDao: RememberedTabDao,
) { ) {
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!) val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
@ -362,85 +350,5 @@ class AppUpgradeHandler
} }
} }
} }
if (previous.isEqualOrBefore(Version.fromString("1.0.1-2-g0"))) {
Timber.i("Starting tab migration")
val users = mutableMapOf<UUID, JellyfinUser?>()
val tabs =
appPreferences.data.first().interfacePreferences.rememberedTabsMap.mapNotNull { (key, tabIndex) ->
// "serverId_userId_itemId", itemId can have underscores
try {
val firstUnderScore = key.indexOf("_").takeIf { it >= 1 }
if (firstUnderScore != null) {
val secondUnderscore =
key
.indexOf("_", startIndex = firstUnderScore + 1)
.takeIf { it >= 1 }
if (secondUnderscore != null) {
val serverId = key.substring(0, firstUnderScore).toUUIDOrNull()
val userId =
key
.substring(firstUnderScore + 1, secondUnderscore)
.toUUIDOrNull()
val itemId = key.substring(secondUnderscore + 1, key.length)
if (userId != null && serverId != null) {
return@mapNotNull users
.getOrPut(userId) {
serverDao.getUser(serverId, userId)
}?.let {
RememberedTab(it.rowId, itemId, tabIndex)
}
}
}
}
} catch (ex: Exception) {
Timber.w(ex, "Error parsing '%s'", key)
}
null
}
Timber.v("Migrating %s tabs", tabs.size)
try {
if (tabs.isNotEmpty()) {
rememberedTabDao.saveAll(tabs)
}
appPreferences.updateData {
it.updateInterfacePreferences {
clearRememberedTabs()
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error saving migrated tabs")
}
}
if (previous.isEqualOrBefore(Version.fromString("1.0.2-8-g0"))) {
appPreferences.updateData {
it.updateSubtitlePreferences {
useSeparateHdr = it.interfacePreferences.shouldEnableSeparateHdrToggle()
}
}
}
if (previous.isEqualOrBefore(Version.fromString("1.0.6-0-g0"))) {
appPreferences.updateData {
it.updateScreensaverPreferences {
dimPercent = ScreensaverPreference.DimPercentage.defaultValue.toInt()
}
}
}
if (previous.isEqualOrBefore(Version.fromString("1.0.6-7-g0"))) {
// preferAc3Surround was mistaken enabled by default, reset it only if the user hasn't enabled experimental settings
appPreferences.updateData {
if (!it.experimentalPreferences.enabled) {
it.updateExperimentalPreferences {
preferAc3Surround =
ExperimentalPreference.PreferAc3ForSurround.defaultValue
}
} else {
it
}
}
}
} }
} }

View file

@ -18,8 +18,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.BackdropStyle import com.github.damontecres.wholphin.preferences.BackdropStyle
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -55,7 +55,7 @@ class BackdropService
* Update the backdrop to use the specified item * Update the backdrop to use the specified item
*/ */
suspend fun submit(item: BaseItem) = suspend fun submit(item: BaseItem) =
withContext(WholphinDispatchers.IO) { withContext(Dispatchers.IO) {
val imageUrl = val imageUrl =
if (item.type == BaseItemKind.GENRE) { if (item.type == BaseItemKind.GENRE) {
item.imageUrlOverride item.imageUrlOverride
@ -76,7 +76,7 @@ class BackdropService
suspend fun submit( suspend fun submit(
itemId: String, itemId: String,
imageUrl: String?, imageUrl: String?,
) = withContext(WholphinDispatchers.IO) { ) = withContext(Dispatchers.IO) {
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) { if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
_backdropFlow.update { _backdropFlow.update {
it.copy( it.copy(
@ -132,7 +132,7 @@ class BackdropService
} }
suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors = suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
withContext(WholphinDispatchers.IO) { withContext(Dispatchers.IO) {
if (imageUrl.isNullOrBlank()) { if (imageUrl.isNullOrBlank()) {
return@withContext ExtractedColors.DEFAULT return@withContext ExtractedColors.DEFAULT
} }

View file

@ -9,12 +9,12 @@ import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheBuilder
import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -56,7 +56,7 @@ class DatePlayedService
adjacentTo = item.id, adjacentTo = item.id,
limit = 1, limit = 1,
) )
return scope.async(WholphinDispatchers.IO) { return scope.async(Dispatchers.IO) {
val premiereDate = item.data.premiereDate val premiereDate = item.data.premiereDate
try { try {
val result = val result =
@ -92,7 +92,7 @@ class DatePlayedService
* the previous episode's last played timestamp, and the episode's premiere date * the previous episode's last played timestamp, and the episode's premiere date
*/ */
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? = suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
withContext(WholphinDispatchers.IO) { withContext(Dispatchers.IO) {
val seriesId = item.data.seriesId val seriesId = item.data.seriesId
return@withContext if (seriesId != null) { return@withContext if (seriesId != null) {
datePlayedCache datePlayedCache

View file

@ -1,15 +1,12 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.PlaybackOverrides
import com.github.damontecres.wholphin.preferences.enabled
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
import com.github.damontecres.wholphin.util.profile.createDeviceProfile import com.github.damontecres.wholphin.util.profile.createDeviceProfile
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -37,17 +34,20 @@ class DeviceProfileService
private var deviceProfile: DeviceProfile? = null private var deviceProfile: DeviceProfile? = null
suspend fun getOrCreateDeviceProfile( suspend fun getOrCreateDeviceProfile(
appPrefs: AppPreferences, prefs: PlaybackPreferences,
serverVersion: ServerVersion?, serverVersion: ServerVersion?,
): DeviceProfile = ): DeviceProfile =
withContext(WholphinDispatchers.Default) { withContext(Dispatchers.Default) {
val prefs = appPrefs.playbackPreferences
mutex.withLock { mutex.withLock {
val newConfig = val newConfig =
DeviceProfileConfiguration( DeviceProfileConfiguration(
maxBitrate = prefs.maxBitrate.toInt(), maxBitrate = prefs.maxBitrate.toInt(),
overrides = prefs.overrides, isAC3Enabled = prefs.overrides.ac3Supported,
experimental = appPrefs.experimentalPreferences, downMixAudio = prefs.overrides.downmixStereo,
assPlaybackMode = prefs.overrides.assPlaybackMode,
pgsDirectPlay = prefs.overrides.directPlayPgs,
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
decodeAv1 = prefs.overrides.decodeAv1,
jellyfinTenEleven = jellyfinTenEleven =
serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), serverVersion != null && serverVersion >= ServerVersion(10, 11, 0),
) )
@ -57,13 +57,12 @@ class DeviceProfileService
createDeviceProfile( createDeviceProfile(
mediaTest = mediaCodecCapabilitiesTest, mediaTest = mediaCodecCapabilitiesTest,
maxBitrate = newConfig.maxBitrate, maxBitrate = newConfig.maxBitrate,
isAC3Enabled = newConfig.overrides.ac3Supported, isAC3Enabled = newConfig.isAC3Enabled,
downMixAudio = newConfig.overrides.downmixStereo, downMixAudio = newConfig.downMixAudio,
assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
pgsDirectPlay = newConfig.overrides.directPlayPgs, pgsDirectPlay = newConfig.pgsDirectPlay,
dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL, dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
decodeAv1 = prefs.overrides.decodeAv1, decodeAv1 = prefs.overrides.decodeAv1,
preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround },
jellyfinTenEleven = newConfig.jellyfinTenEleven, jellyfinTenEleven = newConfig.jellyfinTenEleven,
) )
} }
@ -77,7 +76,11 @@ class DeviceProfileService
*/ */
data class DeviceProfileConfiguration( data class DeviceProfileConfiguration(
val maxBitrate: Int, val maxBitrate: Int,
val overrides: PlaybackOverrides, val isAC3Enabled: Boolean,
val experimental: ExperimentalPreferences, val downMixAudio: Boolean,
val assPlaybackMode: AssPlaybackMode,
val pgsDirectPlay: Boolean,
val dolbyVisionELDirectPlay: Boolean,
val decodeAv1: Boolean,
val jellyfinTenEleven: Boolean, val jellyfinTenEleven: Boolean,
) )

View file

@ -5,8 +5,6 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.pluralRes import com.github.damontecres.wholphin.data.pluralRes
import com.github.damontecres.wholphin.data.stringRes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -50,10 +48,9 @@ class ExtrasService
if (items.size == 1) { if (items.size == 1) {
val item = items.first() val item = items.first()
val title = val title =
item.title.takeIf { it.isNotNullOrBlank() } item.title ?: context.resources.getQuantityString(type.pluralRes, 1)
?: context.resources.getQuantityString(type.pluralRes, 1)
val subtitle = val subtitle =
if (item.title.isNotNullOrBlank()) { if (item.title == null) {
context.resources.getQuantityString(type.pluralRes, 1) context.resources.getQuantityString(type.pluralRes, 1)
} else { } else {
null null
@ -67,14 +64,14 @@ class ExtrasService
imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY),
) )
} else if (items.size > 1) { } else if (items.size > 1) {
val title = context.resources.getString(type.stringRes) val title =
context.resources.getQuantityString(type.pluralRes, items.size)
val subtitle = val subtitle =
context.resources.getQuantityString( context.resources.getQuantityString(
R.plurals.items, R.plurals.items,
items.size, items.size,
items.size, items.size,
) )
val itemForImage = items.random()
ExtrasItem.Group( ExtrasItem.Group(
parentId = itemId, parentId = itemId,
type = type, type = type,
@ -84,10 +81,9 @@ class ExtrasService
isPlayed = items.all { it.played }, isPlayed = items.all { it.played },
imageUrl = imageUrl =
imageUrlService.getItemImageUrl( imageUrlService.getItemImageUrl(
itemForImage, items.random(),
ImageType.PRIMARY, ImageType.PRIMARY,
), ),
aspectRatio = itemForImage.aspectRatio,
) )
} else { } else {
null null

View file

@ -1,270 +0,0 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter
import com.github.damontecres.wholphin.data.filter.DecadeFilter
import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy
import com.github.damontecres.wholphin.data.filter.DiscoverMovieContentRatingFilter
import com.github.damontecres.wholphin.data.filter.DiscoverMovieGenreFilter
import com.github.damontecres.wholphin.data.filter.DiscoverMovieStudiosFilter
import com.github.damontecres.wholphin.data.filter.DiscoverRatingFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvContentRatingFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvGenreFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvStatusFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvStudiosFilter
import com.github.damontecres.wholphin.data.filter.FavoriteFilter
import com.github.damontecres.wholphin.data.filter.FilterBy
import com.github.damontecres.wholphin.data.filter.FilterValueOption
import com.github.damontecres.wholphin.data.filter.FilterVideoType
import com.github.damontecres.wholphin.data.filter.GenreFilter
import com.github.damontecres.wholphin.data.filter.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.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.showToast
import com.mayakapps.kache.InMemoryKache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.filterApi
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.UUID
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
import java.util.TreeSet
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours
/**
* Get the possible values for filters in a library
*/
@Singleton
class FilterOptionCache
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:IoCoroutineScope private val ioCoroutineScope: CoroutineScope,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val seerrService: SeerrService,
) {
private val cache =
InMemoryKache<FilterOptionCacheKey, List<FilterValueOption>>(16) {
creationScope = ioCoroutineScope
expireAfterWriteDuration = 1.hours
}
/**
* Gets the possible values for a filter
*
* For example, the possible genres in the parent ID
*/
suspend fun getFilterOptionValues(
parentId: UUID?,
filterOption: FilterBy<*, *>,
): List<FilterValueOption> {
val cacheKey = FilterOptionCacheKey(serverRepository.currentUser?.id, parentId, filterOption)
return try {
cache
.getOrPut(cacheKey) { (userId, parentId, filterOption) ->
getFilterOptionValues(userId, parentId, filterOption)
}.orEmpty()
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error fetching options for %s", filterOption)
showToast(context, "Error occurred: ${ex.localizedMessage}")
emptyList()
}
}
private suspend fun getFilterOptionValues(
userId: UUID?,
parentId: UUID?,
filterOption: FilterBy<*, *>,
) = when (filterOption) {
GenreFilter -> {
api.genresApi
.getGenres(
parentId = parentId,
userId = userId,
).content.items
.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,
-> {
listOf(
FilterValueOption("True", null),
FilterValueOption("False", null),
)
}
OfficialRatingFilter -> {
val ratings =
api.localizationApi
.getParentalRatings()
.content
.associate {
it.name to it.value
}
api.filterApi
.getQueryFiltersLegacy(
parentId = parentId,
userId = userId,
).content.officialRatings
?.map { r ->
val value = ratings[r] ?: 0
FilterValueOption(r, value)
}?.sortedBy { it.value as Int }
.orEmpty()
}
VideoTypeFilter -> {
FilterVideoType.entries.map {
FilterValueOption(it.readable, it)
}
}
YearFilter -> {
api.yearsApi
.getYears(
parentId = parentId,
userId = userId,
sortBy = listOf(ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.ASCENDING),
).content.items
.mapNotNull {
it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) }
}
}
DecadeFilter -> {
val items = TreeSet<Int>()
api.yearsApi
.getYears(
parentId = parentId,
userId = userId,
sortBy = listOf(ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.ASCENDING),
).content.items
.mapNotNullTo(items) {
it.name
?.toIntOrNull()
?.div(10)
?.times(10)
}
items.toList().sorted().map { FilterValueOption("$it's", it) }
}
CommunityRatingFilter -> {
(1..10).map {
FilterValueOption("$it", it)
}
}
is DiscoverFilterBy -> {
getDiscoverFilterOptionValues(filterOption)
}
}
private suspend fun getDiscoverFilterOptionValues(filterOption: DiscoverFilterBy<*>): List<FilterValueOption> {
if (seerrService.active.firstOrNull() != true) {
return emptyList()
}
return when (filterOption) {
DiscoverMovieGenreFilter -> {
seerrService.api.searchApi
.discoverGenresliderMovieGet()
.filter { it.name != null && it.id != null }
.map {
FilterValueOption(it.name!!, it.id!!)
}
}
DiscoverTvGenreFilter -> {
seerrService.api.searchApi
.discoverGenresliderTvGet()
.filter { it.name != null && it.id != null }
.map {
FilterValueOption(it.name!!, it.id!!)
}
}
DiscoverMovieStudiosFilter -> {
// TODO region
seerrService.api.otherApi
.watchprovidersMoviesGet("US")
.filter { it.name != null && it.id != null }
// TODO not what this is for
// .sortedBy { it.displayPriority }
.map {
// TODO logo?
FilterValueOption(it.name!!, it.id!!)
}
}
DiscoverTvStudiosFilter -> {
// TODO region
seerrService.api.otherApi
.watchprovidersTvGet("US")
.filter { it.name != null && it.id != null }
// .sortedBy { it.displayPriority }
.map {
FilterValueOption(it.name!!, it.id!!)
}
}
DiscoverTvStatusFilter -> {
TvShowStatus.entries.map {
FilterValueOption(it.value, it)
}
}
DiscoverMovieContentRatingFilter -> {
listOf("NR", "G", "PG", "PG-13", "R", "NC-17")
.map { FilterValueOption(it, it) }
}
DiscoverTvContentRatingFilter -> {
listOf("NR", "TV-Y", "TV-Y7", "TV-G", "TV-PG", "TV-14", "TV-MA")
.map { FilterValueOption(it, it) }
}
DiscoverRatingFilter -> {
(1..10).map {
FilterValueOption("$it", it)
}
}
}
}
private data class FilterOptionCacheKey(
val userId: UUID?,
val parentId: UUID?,
val filterOption: FilterBy<*, *>,
)
}

View file

@ -7,19 +7,16 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomePageSettings import com.github.damontecres.wholphin.data.model.HomePageSettings
import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION 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.createGenreDestination
import com.github.damontecres.wholphin.data.model.createStudioDestination import com.github.damontecres.wholphin.data.model.createStudioDestination
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.HomeItemFields
import com.github.damontecres.wholphin.ui.ProgramItemFields
import com.github.damontecres.wholphin.ui.components.getGenreImageMap import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.formatTypeName
import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions
import com.github.damontecres.wholphin.ui.playback.getTypeFor import com.github.damontecres.wholphin.ui.playback.getTypeFor
import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
@ -29,16 +26,15 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.ui.util.StringStringProvider
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetArtistsHandler
import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -57,22 +53,20 @@ import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.DateTime import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest 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.GetRecordingsRequest
import org.jellyfin.sdk.model.api.request.GetStudiosRequest import org.jellyfin.sdk.model.api.request.GetStudiosRequest
import timber.log.Timber import timber.log.Timber
@ -245,7 +239,7 @@ class HomeSettingsService
settings.rows.mapIndexed { index, config -> settings.rows.mapIndexed { index, config ->
resolve(index, config) resolve(index, config)
} }
HomePageResolvedSettings(userId, resolvedRows) HomePageResolvedSettings(resolvedRows)
} else { } else {
createDefault(userId) createDefault(userId)
} }
@ -256,15 +250,12 @@ class HomeSettingsService
/** /**
* Resolve the settings and set them to be the current settings * Resolve the settings and set them to be the current settings
*/ */
suspend fun updateCurrent( suspend fun updateCurrent(settings: HomePageSettings) {
userId: UUID,
settings: HomePageSettings,
) {
val resolvedRows = val resolvedRows =
settings.rows.mapIndexed { index, config -> settings.rows.mapIndexed { index, config ->
resolve(index, config) resolve(index, config)
} }
val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows) val resolvedSettings = HomePageResolvedSettings(resolvedRows)
currentSettings.update { resolvedSettings } currentSettings.update { resolvedSettings }
} }
@ -284,25 +275,20 @@ class HomeSettingsService
val includedIds = val includedIds =
libraries libraries
.mapIndexed { index, library -> .mapIndexed { index, it ->
val parentId = library.itemId val parentId = it.itemId
val title = getRecentlyAddedTitle(library.name) val title = getRecentlyAddedTitle(it)
if (library.collectionType == CollectionType.LIVETV) { if (it.collectionType == CollectionType.LIVETV) {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = index, id = index,
title = ResStringProvider(R.string.watch_live), title = ResStringProvider(R.string.live_tv),
config = HomeRowConfig.TvPrograms(), config = HomeRowConfig.TvPrograms(),
) )
} else { } else {
val viewOptions = viewOptionsForCollectionType(library.collectionType)
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = index, id = index,
title = title, title = title,
config = config = HomeRowConfig.RecentlyAdded(parentId),
HomeRowConfig.RecentlyAdded(
parentId = parentId,
viewOptions = viewOptions,
),
) )
} }
} }
@ -315,7 +301,7 @@ class HomeSettingsService
), ),
) )
val rowConfig = continueWatchingRow + includedIds val rowConfig = continueWatchingRow + includedIds
return HomePageResolvedSettings(userId, rowConfig) return HomePageResolvedSettings(rowConfig)
} }
/** /**
@ -332,10 +318,12 @@ class HomeSettingsService
val userDto by api.userApi.getUserById(userId) val userDto by api.userApi.getUserById(userId)
val config = userDto.configuration ?: DefaultUserConfiguration val config = userDto.configuration ?: DefaultUserConfiguration
val libraries = val libraries =
navDrawerService api.userViewsApi
.getAllUserLibraries(userId, userDto.tvAccess) .getUserViews(userId = userId)
.filterNot { .content.items
it.itemId in config.latestItemsExcludes .filter {
it.collectionType in supportedHomeCollectionTypes &&
it.id !in config.latestItemsExcludes
} }
return if (customPrefs.isNotEmpty()) { return if (customPrefs.isNotEmpty()) {
@ -379,7 +367,7 @@ class HomeSettingsService
if (userDto.tvAccess) { if (userDto.tvAccess) {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id++, id = id++,
title = ResStringProvider(R.string.watch_live), title = ResStringProvider(R.string.live_tv),
config = HomeRowConfig.TvPrograms(), config = HomeRowConfig.TvPrograms(),
) )
} else { } else {
@ -408,21 +396,15 @@ class HomeSettingsService
} }
} }
if (sectionType == HomeSectionType.LATEST_MEDIA) { if (sectionType == HomeSectionType.LATEST_MEDIA) {
libraries.map { library -> libraries.map {
val viewOptions =
viewOptionsForCollectionType(library.collectionType)
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id++, id = id++,
title = title =
ResArgStringProvider( ResArgStringProvider(
R.string.recently_added_in, R.string.recently_added_in,
library.name ?: "", it.name ?: "",
),
config =
HomeRowConfig.RecentlyAdded(
parentId = library.itemId,
viewOptions = viewOptions,
), ),
config = HomeRowConfig.RecentlyAdded(it.id),
) )
} }
} else if (config != null) { } else if (config != null) {
@ -431,7 +413,7 @@ class HomeSettingsService
null null
} }
}.flatten() }.flatten()
HomePageResolvedSettings(userId, rowConfigs) HomePageResolvedSettings(rowConfigs)
} else { } else {
null null
} }
@ -521,8 +503,8 @@ class HomeSettingsService
is HomeRowConfig.Favorite -> { is HomeRowConfig.Favorite -> {
val name = val name =
ResProviderStringProvider( ResProviderStringProvider(
R.string.favorite_items_title, R.string.favorite_items,
ResStringProvider(formatTypeName(config.kind)), ResStringProvider(favoriteOptions[config.kind]!!),
) )
HomeRowConfigDisplay(id, name, config) HomeRowConfigDisplay(id, name, config)
} }
@ -538,7 +520,7 @@ class HomeSettingsService
is HomeRowConfig.TvPrograms -> { is HomeRowConfig.TvPrograms -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = ResStringProvider(R.string.watch_live), title = ResStringProvider(R.string.live_tv),
config, config,
) )
} }
@ -568,10 +550,8 @@ class HomeSettingsService
): StringProvider = ): StringProvider =
try { try {
api.userLibraryApi api.userLibraryApi
.getItem( .getItem(itemId = itemId)
userId = serverRepository.currentUser?.id, .content.name
itemId = itemId,
).content.name
?.let { ?.let {
if (stringRes == null) { if (stringRes == null) {
StringStringProvider(it) StringStringProvider(it)
@ -612,7 +592,6 @@ class HomeSettingsService
items = resume, items = resume,
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row, rowType = row,
showViewMore = resume.size >= limit,
) )
} }
@ -632,7 +611,6 @@ class HomeSettingsService
items = nextUp, items = nextUp,
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row, rowType = row,
showViewMore = nextUp.size >= limit,
) )
} }
@ -653,14 +631,17 @@ class HomeSettingsService
prefs.maxDaysNextUp, prefs.maxDaysNextUp,
row.viewOptions.useSeries, row.viewOptions.useSeries,
) )
val combined = latestNextUpService.buildCombined(resume, nextUp)
Success( Success(
title = ResStringProvider(R.string.continue_watching), title = ResStringProvider(R.string.continue_watching),
items = combined.take(limit), items =
latestNextUpService
.buildCombined(
resume,
nextUp,
).take(limit),
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row, rowType = row,
showViewMore = combined.size >= limit,
) )
} }
@ -712,9 +693,6 @@ class HomeSettingsService
listOf(it) listOf(it)
} }
}, },
collectionType =
library?.collectionType
?: CollectionType.UNKNOWN,
), ),
) )
} }
@ -724,7 +702,6 @@ class HomeSettingsService
genres, genres,
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row, rowType = row,
showViewMore = genres.size >= limit,
) )
} }
@ -776,16 +753,17 @@ class HomeSettingsService
title, title,
studios, studios,
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
showViewMore = studios.size >= limit,
) )
} }
is HomeRowConfig.RecentlyAdded -> { is HomeRowConfig.RecentlyAdded -> {
val library = libraries.firstOrNull { it.itemId == row.parentId } val library =
val title = getRecentlyAddedTitle(library?.name) libraries
.firstOrNull { it.itemId == row.parentId }
val title = getRecentlyAddedTitle(library)
val request = val request =
GetLatestMediaRequest( GetLatestMediaRequest(
fields = library.itemFields, fields = SlimItemFields,
imageTypeLimit = 1, imageTypeLimit = 1,
parentId = row.parentId, parentId = row.parentId,
groupItems = true, groupItems = true,
@ -803,16 +781,18 @@ class HomeSettingsService
it, it,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
showViewMore = it.size >= limit,
) )
} }
latest latest
} }
is HomeRowConfig.RecentlyReleased -> { is HomeRowConfig.RecentlyReleased -> {
val library = libraries.firstOrNull { it.itemId == row.parentId } val name =
libraries
.firstOrNull { it.itemId == row.parentId }
?.name
val title = val title =
library?.name?.let { name?.let {
ResArgStringProvider(R.string.recently_released_in, it) ResArgStringProvider(R.string.recently_released_in, it)
} ?: ResStringProvider(R.string.recently_released) } ?: ResStringProvider(R.string.recently_released)
val request = val request =
@ -831,7 +811,7 @@ class HomeSettingsService
SortOrder.ASCENDING, SortOrder.ASCENDING,
SortOrder.DESCENDING, SortOrder.DESCENDING,
), ),
fields = library.itemFields, fields = DefaultItemFields,
recursive = true, recursive = true,
maxPremiereDate = LocalDateTime.now(), maxPremiereDate = LocalDateTime.now(),
isUnaired = false, isUnaired = false,
@ -855,13 +835,11 @@ class HomeSettingsService
it, it,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
showViewMore = it.size >= limit,
) )
} }
} }
is HomeRowConfig.ByParent -> { is HomeRowConfig.ByParent -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val request = val request =
GetItemsRequest( GetItemsRequest(
userId = userDto.id, userId = userDto.id,
@ -870,44 +848,27 @@ class HomeSettingsService
sortBy = sortBy =
row.sort?.let { row.sort?.let {
buildList { buildList {
if (it.sort == ItemSortBy.RANDOM) {
add(ItemSortBy.SORT_NAME)
add(ItemSortBy.RANDOM)
} else {
add(it.sort) add(it.sort)
if (it.sort != ItemSortBy.SORT_NAME) { if (it.sort != ItemSortBy.SORT_NAME) {
add(ItemSortBy.SORT_NAME) add(ItemSortBy.SORT_NAME)
} }
} }
}
}, },
sortOrder = sortOrder =
row.sort?.let { row.sort?.let {
buildList { buildList {
if (it.sort == ItemSortBy.RANDOM) {
add(SortOrder.ASCENDING)
add(it.direction)
} else {
add(it.direction) add(it.direction)
if (it.sort != ItemSortBy.SORT_NAME) { if (it.sort != ItemSortBy.SORT_NAME) {
add(SortOrder.ASCENDING) add(SortOrder.ASCENDING)
} }
} }
}
}, },
limit = limit, limit = limit,
fields = library.itemFields, fields = DefaultItemFields,
) )
// Not using getItemName because we want to throw the 404
val title = val title =
api.userLibraryApi getItemName(null, row.parentId, ResStringProvider(R.string.collection))
.getItem(
userId = serverRepository.currentUser?.id,
itemId = row.parentId,
).content.name
?.let { StringStringProvider(it) }
?: ResStringProvider(R.string.collection)
if (usePaging) { if (usePaging) {
ApiRequestPager( ApiRequestPager(
api, api,
@ -927,7 +888,6 @@ class HomeSettingsService
it, it,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
showViewMore = it.size >= limit,
) )
} }
} }
@ -965,7 +925,6 @@ class HomeSettingsService
it, it,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
showViewMore = it.size >= limit,
) )
} }
} }
@ -973,61 +932,37 @@ class HomeSettingsService
is HomeRowConfig.Favorite -> { is HomeRowConfig.Favorite -> {
val title = val title =
ResProviderStringProvider( ResProviderStringProvider(
R.string.favorite_items_title, R.string.favorite_items,
ResStringProvider(formatTypeName(row.kind)), ResStringProvider(favoriteOptions[row.kind]!!),
) )
val resultList = if (row.kind == BaseItemKind.PERSON) {
when (row.kind) {
BaseItemKind.PERSON -> {
val request = val request =
GetPersonsRequest( GetPersonsRequest(
userId = userDto.id, userId = userDto.id,
limit = limit, limit = limit,
fields = HomeItemFields, fields = DefaultItemFields,
isFavorite = true, isFavorite = true,
enableImages = true, enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY), enableImageTypes = listOf(ImageType.PRIMARY),
) )
GetPersonsHandler
GetPersonsHandler.execute(api, request).toBaseItems()
}
BaseItemKind.MUSIC_ARTIST -> {
val request =
GetArtistsRequest(
userId = userDto.id,
limit = limit,
fields = HomeItemFields,
isFavorite = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetArtistsHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetArtistsHandler
.execute(api, request) .execute(api, request)
.toBaseItems(row.viewOptions.useSeries) .content.items
.map { BaseItem(it, true) }
.let {
Success(
title,
it,
row.viewOptions,
)
} }
}
else -> {
val fields =
if (row.kind == BaseItemKind.BOX_SET) {
HomeItemFieldsBoxSets
} else { } else {
HomeItemFields
}
val request = val request =
GetItemsRequest( GetItemsRequest(
userId = userDto.id, userId = userDto.id,
recursive = true, recursive = true,
limit = limit, limit = limit,
fields = fields, fields = DefaultItemFields,
includeItemTypes = listOf(row.kind), includeItemTypes = listOf(row.kind),
isFavorite = true, isFavorite = true,
) )
@ -1042,27 +977,25 @@ class HomeSettingsService
} else { } else {
GetItemsRequestHandler GetItemsRequestHandler
.execute(api, request) .execute(api, request)
.toBaseItems(row.viewOptions.useSeries) .content.items
} .map { BaseItem(it, row.viewOptions.useSeries) }
} }.let {
}
resultList.let {
Success( Success(
title, title,
it, it,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
showViewMore = it.size >= limit,
) )
} }
} }
}
is HomeRowConfig.Recordings -> { is HomeRowConfig.Recordings -> {
val request = val request =
GetRecordingsRequest( GetRecordingsRequest(
userId = userDto.id, userId = userDto.id,
isInProgress = true, isInProgress = true,
fields = HomeItemFields, fields = DefaultItemFields,
limit = limit, limit = limit,
enableImages = true, enableImages = true,
enableUserData = true, enableUserData = true,
@ -1087,44 +1020,32 @@ class HomeSettingsService
it, it,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
showViewMore = it.size >= limit,
) )
} }
} }
is HomeRowConfig.TvPrograms -> { is HomeRowConfig.TvPrograms -> {
val request = val request =
GetProgramsDto( GetRecommendedProgramsRequest(
userId = userDto.id, userId = userDto.id,
fields = ProgramItemFields, fields = DefaultItemFields,
limit = limit, limit = limit,
enableUserData = true, enableUserData = true,
enableImages = true, enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO), enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
imageTypeLimit = 1, imageTypeLimit = 1,
isAiring = true,
minEndDate = DateTime.now().plusMinutes(1),
) )
if (usePaging) { // paging not supported
ApiRequestPager(
api,
request,
GetProgramsDtoHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
api.liveTvApi api.liveTvApi
.getPrograms(request) .getRecommendedPrograms(request)
.content.items .content.items
.map { BaseItem(it, row.viewOptions.useSeries) } .map { BaseItem(it, row.viewOptions.useSeries) }
}.let { .let {
Success( Success(
ResStringProvider(R.string.watch_live), ResStringProvider(R.string.live_tv),
it, it,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
showViewMore = it.size >= limit,
) )
} }
} }
@ -1133,7 +1054,7 @@ class HomeSettingsService
val request = val request =
GetLiveTvChannelsRequest( GetLiveTvChannelsRequest(
userId = userDto.id, userId = userDto.id,
fields = HomeItemFields, fields = DefaultItemFields,
limit = limit, limit = limit,
enableImages = true, enableImages = true,
) )
@ -1155,7 +1076,6 @@ class HomeSettingsService
it, it,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
showViewMore = it.size >= limit,
) )
} }
} }
@ -1188,7 +1108,6 @@ class HomeSettingsService
suggestions.items, suggestions.items,
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
showViewMore = suggestions.items.size >= limit,
) )
} }
@ -1227,11 +1146,10 @@ data class HomeRowConfigDisplay(
* @see HomePageSettings * @see HomePageSettings
*/ */
data class HomePageResolvedSettings( data class HomePageResolvedSettings(
val userId: UUID,
val rows: List<HomeRowConfigDisplay>, val rows: List<HomeRowConfigDisplay>,
) { ) {
companion object { companion object {
val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList()) val EMPTY = HomePageResolvedSettings(listOf())
} }
} }
@ -1261,56 +1179,10 @@ class UnsupportedHomeSettingsVersionException(
val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") ) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")
fun getRecentlyAddedTitle(name: String?): StringProvider = fun getRecentlyAddedTitle(library: Library?): StringProvider =
name?.let { ResArgStringProvider(R.string.recently_added_in, it) } if (library?.isRecordingFolder == true) {
?: ResStringProvider(R.string.recently_added) ResStringProvider(R.string.recently_recorded)
private val Library?.itemFields: List<ItemFields>
get() =
if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
// Get child count to show in the header
HomeItemFieldsBoxSets
} else { } else {
HomeItemFields library?.name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
} ?: ResStringProvider(R.string.recently_added)
private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)
fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions =
when (collectionType) {
CollectionType.MUSIC,
-> {
HomeRowViewOptions(
heightDp = Cards.HEIGHT_EPISODE,
aspectRatio = AspectRatio.SQUARE,
)
}
CollectionType.PHOTOS,
CollectionType.HOMEVIDEOS,
CollectionType.MUSICVIDEOS,
CollectionType.TRAILERS,
-> {
HomeRowViewOptions(
heightDp = Cards.HEIGHT_EPISODE,
aspectRatio = AspectRatio.WIDE,
)
}
CollectionType.LIVETV,
-> {
HomeRowViewOptions.liveTvDefault
}
CollectionType.MOVIES,
CollectionType.TVSHOWS,
CollectionType.BOXSETS,
CollectionType.BOOKS,
CollectionType.PLAYLISTS,
CollectionType.FOLDERS,
CollectionType.UNKNOWN,
null,
-> {
HomeRowViewOptions()
}
} }

View file

@ -223,11 +223,11 @@ class ImageUrlService
imageType = imageType, imageType = imageType,
maxWidth = maxWidth, maxWidth = maxWidth,
maxHeight = maxHeight, maxHeight = maxHeight,
width = width?.takeIf { it > 0 }, width = width,
height = height?.takeIf { it > 0 }, height = height,
quality = quality, quality = quality,
fillWidth = fillWidth?.takeIf { it > 0 }, fillWidth = fillWidth,
fillHeight = fillHeight?.takeIf { it > 0 }, fillHeight = fillHeight,
tag = tag, tag = tag,
format = format, format = format,
percentPlayed = percentPlayed, percentPlayed = percentPlayed,

View file

@ -1,221 +0,0 @@
package com.github.damontecres.wholphin.services
import android.app.SearchManager
import android.content.Intent
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.nav.Destination
import kotlinx.coroutines.flow.first
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.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class IntentService
@Inject
constructor(
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
) {
suspend fun parseIntent(intent: Intent): IntentResult {
Timber.v("Parsing intent %s", intent)
Timber.v("Intent extras: %s", intent.extras)
Timber.v("Intent data: %s", intent.data)
val action = intent.action ?: intent.data?.host
if (
intent.getStringParam(INTENT_ITEM_TYPE) == null &&
(action == Intent.ACTION_MAIN || action.isNullOrBlank())
) {
// Normal app launch
return IntentResult.NoOp
}
val result = prepare(intent)
if (result != null) {
return result
}
// Existence of this key implies launching from Play Next channel
if (intent.getStringParam(INTENT_ITEM_TYPE) != null) {
return getDestinationFromChannel(intent)?.let {
IntentResult.Target(
destinations = listOf(it),
addHomeToBackStack = false,
)
} ?: IntentResult.Error("Invalid parameters")
}
if (action == Intent.ACTION_SEARCH || action == "search") {
val query = intent.getStringParam(SearchManager.QUERY)
return IntentResult.Target(listOf(Destination.Search(query ?: "")))
}
val itemId = intent.getStringParam("itemId")?.toUUIDOrNull()
val item =
itemId?.let {
try {
api.userLibraryApi
.getItem(itemId)
.content
.let { BaseItem(it) }
} catch (ex: Exception) {
Timber.w(ex, "Error fetching item %s", itemId)
return IntentResult.Error("Could not fetch item $itemId")
}
}
val itemDestination = item?.destination()
val destinations =
when (action) {
Intent.ACTION_VIEW, "view" -> {
if (itemDestination != null) {
listOf(itemDestination)
} else {
// Will go to the home page
emptyList()
}
}
ACTION_PLAYBACK, "play" -> {
if (itemId == null || itemDestination == null) {
return IntentResult.Error("Cannot start playback with no itemId")
}
val position = intent.getLongParam("position")?.coerceAtLeast(0)
val shuffle = intent.getBooleanExtra("shuffle", false)
val playbackDestination =
Destination.Playback(
itemId = itemId,
positionMs = position ?: 0L,
shuffle = shuffle,
)
if (itemDestination is Destination.Playback) {
listOf(playbackDestination)
} else {
listOf(
itemDestination,
playbackDestination,
)
}
}
else -> {
return IntentResult.Error("Invalid action: ${intent.action}")
}
}
return IntentResult.Target(destinations, true)
}
internal suspend fun prepare(intent: Intent): IntentResult? {
val appPrefs = userPreferencesService.flow.first().appPreferences
val userId = intent.getStringParam(INTENT_USER_ID)?.toUUIDOrNull()
val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull()
return if (userId != null && serverId != null) {
Timber.v("Intent switches user")
val user = serverRepository.serverDao.getUser(serverId, userId)
if (user != null && !user.isProtected) {
serverRepository.restoreSession(serverId, userId)
?: IntentResult.Error("Error restoring user")
null
} else {
IntentResult.Error("Cannot switch to specified user")
}
} else {
val profileProtected =
serverRepository.current.value
?.user
?.isProtected == true
if (appPrefs.signInAutomatically && !profileProtected) {
Timber.v("No current user, so restoring last")
val userId = appPrefs.currentUserId?.toUUIDOrNull()
val serverId = appPrefs.currentServerId?.toUUIDOrNull()
if (userId != null && serverId != null) {
val current =
serverRepository.restoreSession(serverId, userId)
?: return IntentResult.Error("Error restoring user")
if (current.user.isProtected) {
IntentResult.Error("Could not auto sign-in, user is protected")
} else {
null
}
} else {
IntentResult.Error("Could not auto sign-in, specify a server & user")
}
} else {
IntentResult.Error("Auto sign-in not enabled or user is protected")
}
}
}
private fun Intent.getStringParam(key: String) = getStringExtra(key) ?: data?.getQueryParameter(key)
private fun Intent.getLongParam(key: String) =
getLongExtra(key, -1).takeIf { it >= 0 } ?: data?.getQueryParameter(key)?.toLongOrNull()
private fun getDestinationFromChannel(intent: Intent): Destination? =
intent.let {
val itemId =
it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull()
val type =
it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull)
if (itemId != null && type != null) {
val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull()
val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull()
val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1)
val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1)
if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) {
Destination.SeriesOverview(
itemId = seriesId,
type = BaseItemKind.SERIES,
seasonEpisode =
SeasonEpisodeIds(
seasonId = seasonId,
seasonNumber = seasonNumber,
episodeId = itemId,
episodeNumber = episodeNumber,
),
)
} else {
Destination.MediaItem(itemId, type)
}
} else {
null
}
}
companion object {
const val INTENT_ITEM_ID = "itemId"
const val INTENT_ITEM_TYPE = "itemType"
const val INTENT_SERIES_ID = "seriesId"
const val INTENT_EPISODE_NUMBER = "epNum"
const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId"
const val INTENT_SERVER_ID = "serverId"
const val INTENT_USER_ID = "userId"
const val ACTION_PLAYBACK = "com.github.damontecres.wholphin.PLAYBACK"
}
}
sealed interface IntentResult {
data class Error(
val message: String,
) : IntentResult
data object NoOp : IntentResult
data class Target(
val destinations: List<Destination>,
val addHomeToBackStack: Boolean = true,
) : IntentResult
}

View file

@ -8,7 +8,8 @@ package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.supportItemKinds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
@ -22,7 +23,6 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
@ -60,8 +60,16 @@ class LatestNextUpService
userId = userId, userId = userId,
fields = SlimItemFields, fields = SlimItemFields,
limit = limit, limit = limit,
mediaTypes = listOf(MediaType.VIDEO), includeItemTypes =
excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null, if (includeEpisodes) {
supportItemKinds
} else {
supportItemKinds
.toMutableSet()
.apply {
remove(BaseItemKind.EPISODE)
}
},
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
val items = val items =
@ -135,14 +143,14 @@ class LatestNextUpService
resume: List<BaseItem>, resume: List<BaseItem>,
nextUp: List<BaseItem>, nextUp: List<BaseItem>,
): List<BaseItem> = ): List<BaseItem> =
withContext(WholphinDispatchers.IO) { withContext(Dispatchers.IO) {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val semaphore = Semaphore(3) val semaphore = Semaphore(3)
val deferred = val deferred =
nextUp nextUp
.filter { it.data.seriesId != null } .filter { it.data.seriesId != null }
.map { item -> .map { item ->
async(WholphinDispatchers.IO) { async(Dispatchers.IO) {
try { try {
semaphore.withPermit { semaphore.withPermit {
datePlayedService.getLastPlayed(item) datePlayedService.getLastPlayed(item)

View file

@ -16,12 +16,12 @@ import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -92,7 +92,7 @@ class LatestNextUpSchedulerService
) )
// Exposed for testing // Exposed for testing
internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
init { init {
serverRepository.current.collectLatestIn(activity.lifecycleScope) { user -> serverRepository.current.collectLatestIn(activity.lifecycleScope) { user ->

View file

@ -1,94 +0,0 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.toServerString
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.TimerInfoDto
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Functions for recording live tv programs
*/
@Singleton
class LiveTvService
@Inject
constructor(
private val api: ApiClient,
) {
/**
* Fetch a tv program by ID
*/
suspend fun fetchProgramForDialog(programId: UUID): BaseItem {
val result =
api.liveTvApi
.getProgram(programId.toServerString())
.content
.let { BaseItem(it) }
return result
}
/**
* Cancel a recording by [timerId]
*
* @param series whether the timerId is a series recording or not
* @param timerId the recording ID
*/
suspend fun cancelRecording(
series: Boolean,
timerId: String?,
): Boolean =
if (timerId != null) {
if (series) {
api.liveTvApi.cancelSeriesTimer(timerId)
} else {
api.liveTvApi.cancelTimer(timerId)
}
true
} else {
false
}
/**
* Record tv program
*
* @param programId the tv program ID
* @param series whether to set up series recording or just a single recording
*/
suspend fun record(
programId: UUID,
series: Boolean,
) {
val d by api.liveTvApi.getDefaultTimer(programId.toServerString())
if (series) {
api.liveTvApi.createSeriesTimer(d)
} else {
val payload =
TimerInfoDto(
id = d.id,
type = d.type,
serverId = d.serverId,
externalId = d.externalId,
channelId = d.channelId,
externalChannelId = d.externalChannelId,
channelName = d.channelName,
programId = d.programId,
externalProgramId = d.externalProgramId,
name = d.name,
overview = d.overview,
startDate = d.startDate,
endDate = d.endDate,
serviceName = d.serviceName,
priority = d.priority,
prePaddingSeconds = d.prePaddingSeconds,
postPaddingSeconds = d.postPaddingSeconds,
isPrePaddingRequired = d.isPrePaddingRequired,
isPostPaddingRequired = d.isPostPaddingRequired,
keepUntil = d.keepUntil,
)
api.liveTvApi.createTimer(payload)
}
}
}

View file

@ -2,14 +2,11 @@ package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.getLogCatLines
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.google.protobuf.MessageLiteOrBuilder
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -25,10 +22,10 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Send reports to the server such as media info or app logs * Send media info to the server
*/ */
@Singleton @Singleton
class ServerReportService class MediaReportService
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
@ -48,65 +45,45 @@ class ServerReportService
/** /**
* Fetch the media info and send it to the server * Fetch the media info and send it to the server
*/ */
fun sendMediaReportFor(itemId: UUID) { fun sendReportFor(itemId: UUID) {
ioScope.launchIO(ExceptionHandler(autoToast = true)) { ioScope.launchIO(ExceptionHandler(autoToast = true)) {
val item = api.userLibraryApi.getItem(itemId = itemId).content val item = api.userLibraryApi.getItem(itemId = itemId).content
sendMediaReportFor(item) sendReportFor(item)
} }
} }
/** /**
* Send the media report for the given item * Send the media report for the given item
*/ */
suspend fun sendMediaReportFor(item: BaseItemDto) { suspend fun sendReportFor(item: BaseItemDto) {
val sources = val sources =
item.mediaSources ?: api.userLibraryApi item.mediaSources ?: api.userLibraryApi
.getItem(itemId = item.id) .getItem(itemId = item.id)
.content.mediaSources .content.mediaSources
val sourcesJson = json.encodeToString(sources) val sourcesJson = json.encodeToString(sources)
val appPreferences = userPreferencesService.getCurrent().appPreferences val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences
val serverVersion = serverRepository.currentServer?.serverVersion val serverVersion = serverRepository.currentServer?.serverVersion
val deviceProfile = val deviceProfile =
deviceProfileService.getOrCreateDeviceProfile(appPreferences, serverVersion) deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion)
val deviceProfileJson = json.encodeToString(deviceProfile) val deviceProfileJson = json.encodeToString(deviceProfile)
val body = val body =
buildLogHeader("Send media info") + "\n\n" +
""" """
playbackPrefs=${appPreferences.playbackPreferences.toStringOneLine()} Send media info
experimental=${appPreferences.experimentalPreferences.toStringOneLine()}
mediaSources=$sourcesJson
deviceProfile=$deviceProfileJson
""".trimIndent()
body.chunked(2048).forEach { Timber.w(it) }
Timber.w("End send media info")
val response by api.clientLogApi.logFile(body)
showToast(context, "Sent! Filename=${response.fileName}")
}
fun buildLogHeader(title: String): String {
val serverVersion = serverRepository.currentServer?.serverVersion
return """
$title
serverVersion=$serverVersion serverVersion=$serverVersion
clientInfo=$clientInfo clientInfo=$clientInfo
flavor=${BuildConfig.FLAVOR}
deviceInfo=$deviceInfo deviceInfo=$deviceInfo
manufacturer=${Build.MANUFACTURER} manufacturer=${Build.MANUFACTURER}
model=${Build.MODEL} model=${Build.MODEL}
apiLevel=${Build.VERSION.SDK_INT} apiLevel=${Build.VERSION.SDK_INT}
""".trimIndent()
}
suspend fun sendAppLogs() { playbackPrefs=${playbackPrefs.toString().replace("\n", ", ").replace("\t", " ")}
val logcat = getLogCatLines().joinToString("\n") { it.text }
val header = buildLogHeader("Send App Logs") deviceProfile=$deviceProfileJson
Timber.w(header)
val response by api.clientLogApi.logFile(header + "\n\n" + logcat) mediaSources=$sourcesJson
""".trimIndent()
Timber.w(body)
val response by api.clientLogApi.logFile(body)
showToast(context, "Sent! Filename=${response.fileName}") showToast(context, "Sent! Filename=${response.fileName}")
} }
} }
private fun MessageLiteOrBuilder.toStringOneLine(): String = toString().replace("\n", ", ").replace("\t", " ")

View file

@ -13,11 +13,8 @@ import androidx.media3.session.MediaSession
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.enabled
import com.github.damontecres.wholphin.preferences.get
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.DetailItemFields import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekBack
@ -27,11 +24,10 @@ import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.PlaybackItemState import com.github.damontecres.wholphin.util.PlaybackItemState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.profile.Codec
import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -39,7 +35,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -49,7 +44,6 @@ import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.api.sockets.subscribe import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaystateCommand import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage import org.jellyfin.sdk.model.api.PlaystateMessage
@ -78,7 +72,6 @@ class MusicService
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService, private val imageUrlService: ImageUrlService,
private val userPreferencesService: UserPreferencesService,
) { ) {
private val _state = MutableStateFlow(MusicServiceState.EMPTY) private val _state = MutableStateFlow(MusicServiceState.EMPTY)
val state: StateFlow<MusicServiceState> = _state val state: StateFlow<MusicServiceState> = _state
@ -86,15 +79,8 @@ class MusicService
private val audioFormats by lazy { listOf(*supportedAudioCodecs) } private val audioFormats by lazy { listOf(*supportedAudioCodecs) }
val player: Player by lazy { val player: Player by lazy {
val disableAudioOffload =
runBlocking {
userPreferencesService
.getCurrent()
.appPreferences.experimentalPreferences
.get { disableAudioOffload } ?: false
}
playerFactory playerFactory
.createAudioPlayer(disableAudioOffload = disableAudioOffload) .createAudioPlayer()
.also { .also {
it.addListener(MusicPlayerListener(it, _state)) it.addListener(MusicPlayerListener(it, _state))
} }
@ -106,12 +92,6 @@ class MusicService
private var activityTracker: TrackActivityPlaybackListener? = null private var activityTracker: TrackActivityPlaybackListener? = null
private var websocketJob: Job? = null private var websocketJob: Job? = null
private suspend fun preferAc3Surround() =
userPreferencesService
.getCurrent()
.appPreferences.experimentalPreferences
.enabled { preferAc3Surround }
/** /**
* Start music playback * Start music playback
* *
@ -182,7 +162,7 @@ class MusicService
userId = serverRepository.currentUser?.id, userId = serverRepository.currentUser?.id,
itemId = itemId, itemId = itemId,
limit = 200, limit = 200,
fields = DetailItemFields, fields = DefaultItemFields,
).content.items ).content.items
.map { BaseItem(it, false) } .map { BaseItem(it, false) }
setQueue(items, false) setQueue(items, false)
@ -197,9 +177,9 @@ class MusicService
items: BlockingList<BaseItem?>, items: BlockingList<BaseItem?>,
startIndex: Int, startIndex: Int,
shuffled: Boolean, shuffled: Boolean,
) = withContext(WholphinDispatchers.IO) { ) = withContext(Dispatchers.IO) {
Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled) Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled)
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
player.setMediaItems(emptyList()) player.setMediaItems(emptyList())
player.shuffleModeEnabled = shuffled player.shuffleModeEnabled = shuffled
} }
@ -215,12 +195,11 @@ class MusicService
shuffled: Boolean, shuffled: Boolean,
) { ) {
Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled) Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled)
val preferAc3Surround = preferAc3Surround()
val mediaItems = val mediaItems =
items items
.filter { it.type == BaseItemKind.AUDIO } .filter { it.type == BaseItemKind.AUDIO }
.map { convert(it, preferAc3Surround) } .map(::convert)
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
player.setMediaItems(mediaItems) player.setMediaItems(mediaItems)
player.shuffleModeEnabled = shuffled player.shuffleModeEnabled = shuffled
updateQueueSize() updateQueueSize()
@ -236,9 +215,8 @@ class MusicService
index: Int? = null, index: Int? = null,
) { ) {
if (item.type == BaseItemKind.AUDIO) { if (item.type == BaseItemKind.AUDIO) {
val preferAc3Surround = preferAc3Surround() val mediaItem = convert(item)
val mediaItem = convert(item, preferAc3Surround) withContext(Dispatchers.Main) {
withContext(WholphinDispatchers.Main) {
if (index != null) { if (index != null) {
player.addMediaItem(index, mediaItem) player.addMediaItem(index, mediaItem)
} else { } else {
@ -250,8 +228,6 @@ class MusicService
start() start()
} }
} }
} else {
Timber.w("Tried to add non-audio type %s: %s", item.type, item.id)
} }
} }
@ -264,7 +240,6 @@ class MusicService
list: BlockingList<BaseItem?>, list: BlockingList<BaseItem?>,
startIndex: Int, startIndex: Int,
) = loading { ) = loading {
val preferAc3Surround = preferAc3Surround()
var remaining = startIndex var remaining = startIndex
list.indices list.indices
.chunked(25) .chunked(25)
@ -275,7 +250,7 @@ class MusicService
list list
.getBlocking(it) .getBlocking(it)
?.takeIf { it.type == BaseItemKind.AUDIO } ?.takeIf { it.type == BaseItemKind.AUDIO }
?.let { convert(it, preferAc3Surround) } ?.let(::convert)
} else { } else {
Timber.v("Skipping $remaining") Timber.v("Skipping $remaining")
remaining-- remaining--
@ -291,36 +266,12 @@ class MusicService
/** /**
* Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag * Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag
*/ */
private fun convert( private fun convert(audio: BaseItem): MediaItem {
audio: BaseItem,
preferAc3Surround: Boolean,
): MediaItem {
val needsAc3Transcode =
preferAc3Surround &&
audio.data.mediaSources
?.firstOrNull()
?.mediaStreams
?.any { stream ->
stream.type == MediaStreamType.AUDIO &&
stream.channels.gt(2)
stream.codec != Codec.Audio.AC3
} == true
val url = val url =
if (needsAc3Transcode) {
api.universalAudioApi.getUniversalAudioStreamUrl(
itemId = audio.id,
container = listOf("mka"),
transcodingContainer = "mka",
maxAudioChannels = 6,
transcodingAudioChannels = 6,
audioCodec = Codec.Audio.AC3,
)
} else {
api.universalAudioApi.getUniversalAudioStreamUrl( api.universalAudioApi.getUniversalAudioStreamUrl(
itemId = audio.id, itemId = audio.id,
container = audioFormats, container = audioFormats,
) )
}
Timber.i("url=%s", url) Timber.i("url=%s", url)
val imageUrl = val imageUrl =
audio.data.albumId?.let { albumId -> audio.data.albumId?.let { albumId ->
@ -342,7 +293,7 @@ class MusicService
*/ */
private suspend fun updateQueueSize() { private suspend fun updateQueueSize() {
// val ids = // val ids =
// withContext(WholphinDispatchers.Default) { // withContext(Dispatchers.Default) {
// (0..<player.mediaItemCount).map { player.getMediaItemAt(it).mediaId.toUUID() } // (0..<player.mediaItemCount).map { player.getMediaItemAt(it).mediaId.toUUID() }
// } // }
val timeline = onMain { player.currentTimeline } val timeline = onMain { player.currentTimeline }
@ -353,7 +304,7 @@ class MusicService
timeline.getWindow(it, window) timeline.getWindow(it, window)
window.mediaItem.mediaId.toUUID() window.mediaItem.mediaId.toUUID()
}.toSet() }.toSet()
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
_state.update { _state.update {
it.copy( it.copy(
queueVersion = it.queueVersion + 1, queueVersion = it.queueVersion + 1,
@ -370,7 +321,7 @@ class MusicService
suspend fun moveQueue( suspend fun moveQueue(
index: Int, index: Int,
direction: MoveDirection, direction: MoveDirection,
) = withContext(WholphinDispatchers.Main) { ) = withContext(Dispatchers.Main) {
player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1) player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1)
updateQueueSize() updateQueueSize()
} }
@ -381,7 +332,7 @@ class MusicService
suspend fun moveQueue( suspend fun moveQueue(
index: Int, index: Int,
newIndex: Int, newIndex: Int,
) = withContext(WholphinDispatchers.Main) { ) = withContext(Dispatchers.Main) {
player.moveMediaItem(index, newIndex) player.moveMediaItem(index, newIndex)
updateQueueSize() updateQueueSize()
} }
@ -401,7 +352,7 @@ class MusicService
* Play this item next after the current, ie add the item as the next index in the queue * Play this item next after the current, ie add the item as the next index in the queue
*/ */
suspend fun playNext(song: BaseItem) { suspend fun playNext(song: BaseItem) {
val mediaItem = convert(song, preferAc3Surround()) val mediaItem = convert(song)
onMain { onMain {
player.addMediaItem(state.value.currentIndex + 1, mediaItem) player.addMediaItem(state.value.currentIndex + 1, mediaItem)
if (player.mediaItemCount == 1) { if (player.mediaItemCount == 1) {
@ -434,7 +385,7 @@ class MusicService
.subscribe<PlaystateMessage>() .subscribe<PlaystateMessage>()
.onEach { message -> .onEach { message ->
message.data?.let { message.data?.let {
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
when (it.command) { when (it.command) {
PlaystateCommand.STOP -> { PlaystateCommand.STOP -> {
stop() stop()

View file

@ -6,27 +6,27 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.supportedCollectionTypes import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
@ -52,37 +52,35 @@ class NavDrawerService
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
private val musicService: MusicService, private val musicService: MusicService,
) { ) {
private val _state = MutableStateFlow(NavDrawerItemState()) private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
val state: StateFlow<NavDrawerItemState> = _state val state: StateFlow<NavDrawerItemState> = _state
init { init {
// Handle updating the nav drawer when the user changes // Handle updating the nav drawer when the user changes
combine( serverRepository.currentUserFlow
serverRepository.currentUserFlow, .combine(serverRepository.currentUserDtoFlow) { user, userDto ->
serverRepository.currentUserDtoFlow, Pair(user, userDto)
seerrServerRepository.active, }.onEach { (user, userDto) ->
) { user, userDto, discoverActive -> Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id)
Triple(user, userDto, discoverActive) _state.update {
}.collectLatestIn(coroutineScope) { (user, userDto, discoverActive) -> it.copy(
Timber.d( items = emptyList(),
"User updated: user=%s, userDto=%s, discoverActive=%s", moreItems = emptyList(),
user?.id,
userDto?.id,
discoverActive,
) )
try {
if (user != null && userDto != null && user.id == userDto.id) {
updateNavDrawer(user, userDto, discoverActive)
} else {
_state.update { NavDrawerItemState() }
} }
} catch (ex: CancellationException) { if (user != null && userDto != null && user.id == userDto.id) {
throw ex updateNavDrawer(user, userDto)
} catch (ex: Exception) { }
}.catch { ex ->
Timber.e(ex, "Error updating nav drawer") Timber.e(ex, "Error updating nav drawer")
showToast(context, "Error fetching user's views") showToast(context, "Error fetching user's views")
} }.launchIn(coroutineScope)
}
// Handle when the user has logged into a Seerr server
seerrServerRepository.active
.onEach { discoverActive ->
_state.update { it.copy(discoverEnabled = discoverActive) }
}.launchIn(coroutineScope)
// Handle when music is actively playing or not // Handle when music is actively playing or not
coroutineScope.launchDefault { coroutineScope.launchDefault {
@ -134,22 +132,13 @@ class NavDrawerService
.content.items .content.items
val recordingFolders = val recordingFolders =
if (tvAccess) { if (tvAccess) {
try {
api.liveTvApi api.liveTvApi
.getRecordingFolders(userId = userId) .getRecordingFolders(userId = userId)
.content.items .content.items
.map { it.id } .map { it.id }
.toSet() .toSet()
} catch (ex: InvalidStatusException) {
if (ex.status == 401 || ex.status == 403) {
Timber.w("Got HTTP %s querying for recording folders", ex.status)
emptySet()
} else { } else {
throw ex setOf()
}
}
} else {
emptySet()
} }
val libraries = val libraries =
userViews userViews
@ -189,13 +178,8 @@ class NavDrawerService
suspend fun updateNavDrawer( suspend fun updateNavDrawer(
user: JellyfinUser, user: JellyfinUser,
userDto: UserDto, userDto: UserDto,
discoverActive: Boolean,
) { ) {
val builtins = val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
buildList {
add(NavDrawerItem.Favorites)
if (discoverActive) add(NavDrawerItem.Discover)
}
val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess) val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess)
val libraries = val libraries =
allLibraries allLibraries
@ -220,7 +204,7 @@ class NavDrawerService
val allItems = builtins + libraries val allItems = builtins + libraries
val navDrawerPins = val navDrawerPins =
withContext(WholphinDispatchers.IO) { withContext(Dispatchers.IO) {
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
} }
@ -245,18 +229,21 @@ class NavDrawerService
it.copy( it.copy(
items = items, items = items,
moreItems = moreItems, moreItems = moreItems,
allLibraries = allLibraries,
) )
} }
} }
} }
data class NavDrawerItemState( data class NavDrawerItemState(
val items: List<NavDrawerItem> = emptyList(), val items: List<NavDrawerItem>,
val moreItems: List<NavDrawerItem> = emptyList(), val moreItems: List<NavDrawerItem>,
val nowPlayingEnabled: Boolean = false, val discoverEnabled: Boolean,
val nowPlayingTitle: String? = null, val nowPlayingEnabled: Boolean,
val allLibraries: List<Library> = emptyList(), val nowPlayingTitle: String?,
) ) {
companion object {
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null)
}
}
val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true

View file

@ -24,21 +24,19 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.exoplayer.video.VideoRendererEventListener
import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.session.MediaSession
import com.github.damontecres.wholphin.mpv.MpvPlayer
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.get
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.peerless2012.ass.media.AssHandler import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.factory.AssRenderersFactory import io.github.peerless2012.ass.media.factory.AssRenderersFactory
import io.github.peerless2012.ass.media.kt.withAssMkvSupport import io.github.peerless2012.ass.media.kt.withAssMkvSupport
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
import io.github.peerless2012.ass.media.type.AssRenderType import io.github.peerless2012.ass.media.type.AssRenderType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
@ -62,10 +60,9 @@ class PlayerFactory
suspend fun createVideoPlayer( suspend fun createVideoPlayer(
backend: PlayerBackend, backend: PlayerBackend,
appPreferences: AppPreferences, prefs: PlaybackPreferences,
): PlayerCreation { ): PlayerCreation {
val prefs = appPreferences.playbackPreferences withContext(Dispatchers.Main) {
withContext(WholphinDispatchers.Main) {
if (currentPlayer?.isReleased == false) { if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!") Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release() currentPlayer?.release()
@ -107,7 +104,6 @@ class PlayerFactory
WholphinRenderersFactory(context, decodeAv1) WholphinRenderersFactory(context, decodeAv1)
.setEnableDecoderFallback(true) .setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode) .setExtensionRendererMode(rendererMode)
val mediaSourceFactory = val mediaSourceFactory =
if (useLibAss) { if (useLibAss) {
val renderType = val renderType =
@ -132,15 +128,7 @@ class PlayerFactory
extractorsFactory, extractorsFactory,
) )
} }
val disableAudioOffload = val trackSelector = createTrackSelector()
appPreferences.experimentalPreferences.get { disableAudioOffload } ?: false
val tunneling =
appPreferences.experimentalPreferences.get { videoTunnelingEnabled }
val trackSelector =
createTrackSelector(
tunneling = tunneling,
disableAudioOffload = disableAudioOffload,
)
ExoPlayer ExoPlayer
.Builder(context) .Builder(context)
@ -150,7 +138,7 @@ class PlayerFactory
.build() .build()
.apply { .apply {
assHandler?.init(this) assHandler?.init(this)
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
setAudioAttributes( setAudioAttributes(
AudioAttributes AudioAttributes
.Builder() .Builder()
@ -170,10 +158,7 @@ class PlayerFactory
return PlayerCreation(newPlayer, assHandler) return PlayerCreation(newPlayer, assHandler)
} }
fun createAudioPlayer( fun createAudioPlayer(extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK): ExoPlayer {
disableAudioOffload: Boolean,
extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK,
): ExoPlayer {
val rendererMode = val rendererMode =
when (extensions) { when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
@ -191,7 +176,7 @@ class PlayerFactory
OkHttpDataSource.Factory(authOkHttpClient), OkHttpDataSource.Factory(authOkHttpClient),
extractorsFactory, extractorsFactory,
) )
val trackSelector = createTrackSelector(disableAudioOffload = disableAudioOffload) val trackSelector = createTrackSelector()
return ExoPlayer return ExoPlayer
.Builder(context) .Builder(context)
.setMediaSourceFactory(mediaSourceFactory) .setMediaSourceFactory(mediaSourceFactory)
@ -202,10 +187,9 @@ class PlayerFactory
it.setAudioAttributes( it.setAudioAttributes(
AudioAttributes AudioAttributes
.Builder() .Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(), .build(),
true, false,
) )
} }
} }
@ -215,33 +199,18 @@ class PlayerFactory
.setConstantBitrateSeekingEnabled(true) .setConstantBitrateSeekingEnabled(true)
.setConstantBitrateSeekingAlwaysEnabled(true) .setConstantBitrateSeekingAlwaysEnabled(true)
private fun createTrackSelector( private fun createTrackSelector() =
tunneling: Boolean? = null, DefaultTrackSelector(context).apply {
disableAudioOffload: Boolean = false,
) = DefaultTrackSelector(context).apply {
val offloadMode =
if (disableAudioOffload) {
AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED
} else {
AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED
}
setParameters( setParameters(
buildUponParameters() buildUponParameters()
.apply { .setAudioOffloadPreferences(
tunneling?.let { setTunnelingEnabled(tunneling) }
}.setAudioOffloadPreferences(
AudioOffloadPreferences AudioOffloadPreferences
.Builder() .Builder()
.setAudioOffloadMode(offloadMode) .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
.build(), .build(),
), ),
) )
} }
fun createMediaSession(player: Player) =
MediaSession
.Builder(context, player)
.build()
} }
val Player.isReleased: Boolean val Player.isReleased: Boolean

View file

@ -8,20 +8,21 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistInfo import com.github.damontecres.wholphin.data.model.PlaylistInfo
import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.ui.DetailItemFields import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.components.baseItemKinds import com.github.damontecres.wholphin.ui.components.baseItemKinds
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.TransformList
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@ -65,7 +66,7 @@ class PlaylistCreator
GetEpisodesRequest( GetEpisodesRequest(
seriesId = seriesId, seriesId = seriesId,
seasonId = seasonId, seasonId = seasonId,
fields = DetailItemFields, fields = DefaultItemFields,
startItemId = episodeId, startItemId = episodeId,
sortBy = if (shuffled) ItemSortBy.RANDOM else null, sortBy = if (shuffled) ItemSortBy.RANDOM else null,
limit = Playlist.MAX_SIZE, limit = Playlist.MAX_SIZE,
@ -77,7 +78,7 @@ class PlaylistCreator
.convertAndAddParts(false) .convertAndAddParts(false)
val startIndex = val startIndex =
episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0 episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0
return Playlist(episodes.subList(startIndex, episodes.size)) return Playlist(episodes, startIndex)
} }
/** /**
@ -94,7 +95,7 @@ class PlaylistCreator
GetItemsRequest( GetItemsRequest(
userId = serverRepository.currentUser?.id, userId = serverRepository.currentUser?.id,
parentId = playlistId, parentId = playlistId,
fields = DetailItemFields, fields = DefaultItemFields,
startIndex = startIndex, startIndex = startIndex,
limit = Playlist.MAX_SIZE, limit = Playlist.MAX_SIZE,
sortBy = listOf(sortAndDirection.sort), sortBy = listOf(sortAndDirection.sort),
@ -103,7 +104,7 @@ class PlaylistCreator
), ),
) )
val items = GetItemsRequestHandler.execute(api, request).content.items val items = GetItemsRequestHandler.execute(api, request).content.items
return Playlist(items.convertAndAddParts()) return Playlist(items.convertAndAddParts(), 0)
} }
private suspend fun createFromCollection( private suspend fun createFromCollection(
@ -135,7 +136,7 @@ class PlaylistCreator
excludeItemIds = listOf(item.id), excludeItemIds = listOf(item.id),
sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) }, sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) },
sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) }, sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) },
fields = DetailItemFields, fields = DefaultItemFields,
startIndex = startIndex, startIndex = startIndex,
limit = Playlist.MAX_SIZE, limit = Playlist.MAX_SIZE,
excludeLocationTypes = listOf(LocationType.VIRTUAL), excludeLocationTypes = listOf(LocationType.VIRTUAL),
@ -146,7 +147,7 @@ class PlaylistCreator
.execute(api, request) .execute(api, request)
.content.items .content.items
.convertAndAddParts() .convertAndAddParts()
return Playlist(items) return Playlist(items, 0)
} }
/** /**
@ -201,20 +202,6 @@ class PlaylistCreator
BaseItemKind.SEASON -> { BaseItemKind.SEASON -> {
val seriesId = item.seriesId val seriesId = item.seriesId
if (seriesId != null) { if (seriesId != null) {
if (shuffled) {
api.tvShowsApi
.getEpisodes(
seriesId = seriesId,
seasonId = item.id,
limit = Playlist.MAX_SIZE,
sortBy = ItemSortBy.RANDOM,
fields = DetailItemFields,
).content.items
.convertAndAddParts()
.let {
PlaylistCreationResult.Success(Playlist(it))
}
} else {
PlaylistCreationResult.Success( PlaylistCreationResult.Success(
createFromEpisode( createFromEpisode(
seriesId = seriesId, seriesId = seriesId,
@ -223,50 +210,20 @@ class PlaylistCreator
shuffled = shuffled, shuffled = shuffled,
), ),
) )
}
} else { } else {
PlaylistCreationResult.Error(null, "Episode has no seriesId") PlaylistCreationResult.Error(null, "Episode has no seriesId")
} }
} }
BaseItemKind.SERIES -> { BaseItemKind.SERIES -> {
if (shuffled) {
api.tvShowsApi
.getEpisodes(
seriesId = item.id,
limit = Playlist.MAX_SIZE,
sortBy = ItemSortBy.RANDOM,
fields = DetailItemFields,
).content.items
.convertAndAddParts()
.let {
PlaylistCreationResult.Success(Playlist(it))
}
} else {
val result by api.tvShowsApi.getNextUp(seriesId = item.id)
val nextUp =
result.items.firstOrNull() ?: api.tvShowsApi
.getEpisodes(
item.id,
limit = 1,
).content.items
.firstOrNull()
if (nextUp != null) {
PlaylistCreationResult.Success( PlaylistCreationResult.Success(
createFromEpisode( createFromEpisode(
seriesId = item.id, seriesId = item.id,
seasonId = null, seasonId = null,
episodeId = nextUp.id, episodeId = null,
shuffled = shuffled, shuffled = shuffled,
), ),
) )
} else {
PlaylistCreationResult.Error(
null,
"Could not determine next up episode for series: " + item.id,
)
}
}
} }
BaseItemKind.PLAYLIST -> { BaseItemKind.PLAYLIST -> {
@ -306,7 +263,7 @@ class PlaylistCreator
}.let(::addAll) }.let(::addAll)
} }
} }
PlaylistCreationResult.Success(Playlist(list)) PlaylistCreationResult.Success(Playlist(list, 0))
} }
// Not support yet // Not support yet
@ -342,50 +299,27 @@ class PlaylistCreator
* Get the playlists on the server for a given media type * Get the playlists on the server for a given media type
*/ */
suspend fun getServerPlaylists( suspend fun getServerPlaylists(
query: String,
mediaType: MediaType?, mediaType: MediaType?,
): List<PlaylistInfo> { scope: CoroutineScope,
val userId = ): List<PlaylistInfo?> {
serverRepository.currentUser?.id ?: throw IllegalStateException("No user found")
val request = val request =
GetItemsRequest( GetItemsRequest(
searchTerm = query.takeIf { it.isNotBlank() },
includeItemTypes = listOf(BaseItemKind.PLAYLIST), includeItemTypes = listOf(BaseItemKind.PLAYLIST),
mediaTypes = mediaType?.let { listOf(mediaType) }, mediaTypes = mediaType?.let { listOf(mediaType) },
recursive = true, recursive = true,
limit = 25,
sortBy = listOf(ItemSortBy.DATE_LAST_CONTENT_ADDED),
sortOrder = listOf(SortOrder.DESCENDING),
) )
val playlists = GetItemsRequestHandler.execute(api, request).content.items val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
return playlists return TransformList(pager) {
.mapNotNull { playlist -> it?.let {
try {
val response = api.playlistsApi.getPlaylistUser(playlist.id, userId).content
if (response.canEdit) {
PlaylistInfo( PlaylistInfo(
id = playlist.id, id = it.id,
name = playlist.name ?: context.getString(R.string.unknown), name = it.name ?: context.getString(R.string.unknown),
count = playlist.childCount ?: 0, count = it.data.childCount ?: 0,
mediaType = playlist.mediaType, mediaType = it.data.mediaType,
) )
} else {
null
}
} catch (ex: InvalidStatusException) {
if (ex.status == 404) {
null
} else {
throw ex
} }
} }
} }
// .sortedWith(
// compareBy<PlaylistInfo> {
// SearchRelevance.score(it.name, BaseItemKind.PLAYLIST, query)
// }.thenBy { it.name },
// )
}
suspend fun createServerPlaylist( suspend fun createServerPlaylist(
name: String, name: String,

View file

@ -8,9 +8,9 @@ import android.os.Looper
import android.view.Display import android.view.Display
import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@ -38,7 +38,7 @@ class RefreshRateService
stream: MediaStream, stream: MediaStream,
switchRefreshRate: Boolean, switchRefreshRate: Boolean,
switchResolution: Boolean, switchResolution: Boolean,
) = withContext(WholphinDispatchers.IO) { ) = withContext(Dispatchers.IO) {
if (!switchRefreshRate && !switchResolution) { if (!switchRefreshRate && !switchResolution) {
Timber.v("Not switching either refresh rate nor resolution") Timber.v("Not switching either refresh rate nor resolution")
return@withContext return@withContext

View file

@ -1,34 +0,0 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.RememberedTab
import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RememberedTabService
@Inject
constructor(
private val serverRepository: ServerRepository,
private val rememberedTabDao: RememberedTabDao,
) {
suspend fun getRememberedTab(itemId: String): Int? =
withContext(WholphinDispatchers.IO) {
serverRepository.currentUser?.rowId?.let { userId ->
rememberedTabDao.getRememberedTab(userId, itemId)?.index
}
}
suspend fun saveRememberedTab(
itemId: String,
tabIndex: Int,
): Unit =
withContext(WholphinDispatchers.IO) {
serverRepository.currentUser?.rowId?.let { userId ->
rememberedTabDao.save(RememberedTab(userId, itemId, tabIndex))
}
}
}

View file

@ -1,9 +1,6 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import coil3.imageLoader import coil3.imageLoader
import coil3.request.ImageRequest import coil3.request.ImageRequest
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
@ -13,10 +10,10 @@ import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -60,7 +57,6 @@ class ScreensaverService
val keepScreenOn = MutableStateFlow(false) val keepScreenOn = MutableStateFlow(false)
private var waitJob: Job? = null private var waitJob: Job? = null
private var dimJob: Job? = null
init { init {
userPreferencesService.flow userPreferencesService.flow
@ -69,14 +65,7 @@ class ScreensaverService
val enabled = val enabled =
prefs.appPreferences.interfacePreferences.screensaverPreference.enabled prefs.appPreferences.interfacePreferences.screensaverPreference.enabled
keepScreenOnInternal(enabled) keepScreenOnInternal(enabled)
ScreensaverState( ScreensaverState(enabled, false, false, false)
enabled = enabled,
enabledTemp = false,
active = false,
paused = false,
dimEnabled = prefs.appPreferences.interfacePreferences.screensaverPreference.dimEnabled,
dimActive = false,
)
} }
}.launchIn(scope) }.launchIn(scope)
} }
@ -90,10 +79,7 @@ class ScreensaverService
// Timber.v("pulse") // Timber.v("pulse")
_state.update { _state.update {
if (!it.active) { if (!it.active) {
it.copy( it.copy(active = false)
active = false,
dimActive = false,
)
} else { } else {
it it
} }
@ -108,30 +94,7 @@ class ScreensaverService
.appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
delay(startDelay) delay(startDelay)
_state.update { _state.update {
it.copy( it.copy(active = true)
active = true,
dimActive = it.dimEnabled,
)
}
}
}
}
dimJob?.cancel()
if (_state.value.dimEnabled) {
_state.update {
it.copy(dimActive = false)
}
if (!_state.value.paused) {
dimJob =
scope.launch(ExceptionHandler()) {
val startDelay =
userPreferencesService
.getCurrent()
.appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
delay(startDelay)
Timber.v("state dim=%s", _state.value)
_state.update {
it.copy(dimActive = !_state.value.paused)
} }
} }
} }
@ -146,7 +109,6 @@ class ScreensaverService
it.copy( it.copy(
enabledTemp = true, enabledTemp = true,
active = true, active = true,
dimActive = true,
) )
} }
} }
@ -159,13 +121,9 @@ class ScreensaverService
it.copy( it.copy(
enabledTemp = false, enabledTemp = false,
active = false, active = false,
dimActive = false,
) )
} }
if (cancelJob) { if (cancelJob) waitJob?.cancel()
waitJob?.cancel()
dimJob?.cancel()
}
} }
/** /**
@ -173,24 +131,17 @@ class ScreensaverService
*/ */
fun keepScreenOn(keep: Boolean) { fun keepScreenOn(keep: Boolean) {
scope.launchDefault { scope.launchDefault {
val screensaverEnabled = state.value.enabled val screensaverEnabled = _state.value.enabled
val dimEnabled = state.value.dimEnabled
Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled) Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled)
if (screensaverEnabled || dimEnabled) { if (screensaverEnabled) {
// Page is requesting to keep screen on, so we don't wait to show the screensaver // Page is requesting to keep screen on, so we don't wait to show the screensaver
_state.update { _state.update {
it.copy( it.copy(active = false, paused = keep)
active = false,
dimActive = false,
paused = keep,
)
} }
if (!keep) { if (!keep) {
pulse() pulse()
} }
} } else {
if (!screensaverEnabled) {
// If in-app screensaver is not enabled, send keep screen on to the OS
keepScreenOnInternal(keep) keepScreenOnInternal(keep)
} }
} }
@ -273,7 +224,7 @@ class ScreensaverService
if (index > pager.lastIndex) index = 0 if (index > pager.lastIndex) index = 0
} }
} }
}.flowOn(WholphinDispatchers.Default).cancellable() }.flowOn(Dispatchers.Default).cancellable()
private suspend fun createPager(): ApiRequestPager<GetItemsRequest> { private suspend fun createPager(): ApiRequestPager<GetItemsRequest> {
val prefs = val prefs =
@ -294,11 +245,6 @@ class ScreensaverService
) )
return ApiRequestPager(api, request, GetItemsRequestHandler, scope).init() return ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
} }
companion object {
val enterAnimation = fadeIn(animationSpec = tween(durationMillis = 1000))
val exitAnimation = fadeOut(animationSpec = tween(durationMillis = 500))
}
} }
data class ScreensaverState( data class ScreensaverState(
@ -306,10 +252,6 @@ data class ScreensaverState(
val enabledTemp: Boolean, val enabledTemp: Boolean,
val active: Boolean, val active: Boolean,
val paused: Boolean, val paused: Boolean,
val dimEnabled: Boolean = false,
val dimActive: Boolean = false,
) { ) {
val show get() = (enabled || enabledTemp) && active && !paused val show get() = (enabled || enabledTemp) && active && !paused
val showDim get() = dimEnabled && dimActive && !paused
} }

View file

@ -6,21 +6,13 @@ import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
import com.github.damontecres.wholphin.api.seerr.model.MediaInfo import com.github.damontecres.wholphin.api.seerr.model.MediaInfo
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.api.seerr.model.MovieResult import com.github.damontecres.wholphin.api.seerr.model.MovieResult
import com.github.damontecres.wholphin.api.seerr.model.RootFolder
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
import com.github.damontecres.wholphin.api.seerr.model.ServiceProfile
import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.api.seerr.model.TvResult import com.github.damontecres.wholphin.api.seerr.model.TvResult
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.ui.detail.discover.SeerrProfile
import com.github.damontecres.wholphin.ui.detail.discover.SeerrRequestData
import com.github.damontecres.wholphin.ui.detail.discover.SeerrRootFolder
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.toLocalDate import com.github.damontecres.wholphin.ui.toLocalDate
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -28,7 +20,6 @@ import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -124,7 +115,21 @@ class SeerrService
} }
BaseItemKind.PERSON -> { BaseItemKind.PERSON -> {
personCredits(it) api.personApi
.personPersonIdCombinedCreditsGet(personId = it)
.let { credits ->
val cast =
credits.cast
?.take(25)
?.map { createDiscoverItem(it) }
.orEmpty()
val crew =
credits.crew
?.take(25)
?.map { createDiscoverItem(it) }
.orEmpty()
cast + crew
}
} }
else -> { else -> {
@ -136,50 +141,6 @@ class SeerrService
null null
} }
/**
* Get the combined movie & TV credits of a person as [DiscoverItem]s
*
* The two lists returned by the API are merged, de-duplicated by id since a person can be
* both cast and crew on the same title, and sorted by release date descending with undated
* entries last
*/
suspend fun personCredits(personId: Int): List<DiscoverItem> =
api.personApi
.personPersonIdCombinedCreditsGet(personId = personId)
.let { credits ->
val cast = credits.cast?.map { createDiscoverItem(it) }.orEmpty()
val crew = credits.crew?.map { createDiscoverItem(it) }.orEmpty()
(cast + crew)
.distinctBy { it.id }
.sortedByDescending { it.releaseDate }
}
/**
* Create a [DiscoverItem] for a person in the library so that the full grid of their
* credits can be opened from their page
*
* @return the item or null if the person has no TMDB id
*/
fun discoverPerson(item: BaseItem): DiscoverItem? =
item.data.providerIds
?.get("Tmdb")
?.toIntOrNull()
?.let {
DiscoverItem(
id = it,
type = SeerrItemType.PERSON,
title = item.name,
subtitle = null,
overview = item.data.overview,
availability = SeerrAvailability.UNKNOWN,
releaseDate = null,
posterUrl = null,
backDropUrl = null,
logoUrl = null,
jellyfinItemId = null,
)
}
suspend fun getTvSeries(item: BaseItem): TvDetails? = suspend fun getTvSeries(item: BaseItem): TvDetails? =
if (active.first()) { if (active.first()) {
item.data.providerIds item.data.providerIds
@ -192,15 +153,20 @@ class SeerrService
null null
} }
suspend fun createImageUrl( private suspend fun createImageUrl(
imageType: ImageType, imageType: ImageType,
path: String?, path: String?,
mediaInfo: MediaInfo?, mediaInfo: MediaInfo?,
backdropHeight: Int = 1080,
backdropWidth: Int = 1920,
): String? { ): String? {
if (mediaInfo != null) { if (mediaInfo != null) {
val itemId = mediaInfo.jellyfinId val itemId =
if (mediaInfo.jellyfinMediaId.isNotNullOrBlank()) {
mediaInfo.jellyfinMediaId.toUUIDOrNull()
} else if (mediaInfo.jellyfinMediaId4k.isNotNullOrBlank()) {
mediaInfo.jellyfinMediaId4k.toUUIDOrNull()
} else {
null
}
if (itemId != null) { if (itemId != null) {
return imageUrlService.getItemImageUrl( return imageUrlService.getItemImageUrl(
itemId = itemId, itemId = itemId,
@ -208,7 +174,6 @@ class SeerrService
) )
} }
} }
return path?.takeIf { it.isNotNullOrBlank() }?.let {
val current = seerrServerRepository.current.firstOrNull() ?: return null val current = seerrServerRepository.current.firstOrNull() ?: return null
val cacheImages = current.serverConfig.cacheImages == true val cacheImages = current.serverConfig.cacheImages == true
val base = val base =
@ -220,132 +185,11 @@ class SeerrService
val prefix = val prefix =
when (imageType) { when (imageType) {
ImageType.PRIMARY -> "/t/p/w500" ImageType.PRIMARY -> "/t/p/w500"
ImageType.BACKDROP -> "/t/p/w${backdropWidth}_and_h${backdropHeight}_multi_faces" ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces"
else -> throw IllegalArgumentException("Image type not supported: $imageType") else -> throw IllegalArgumentException("Image type not supported: $imageType")
} }
return "${base}${prefix}$path" return "${base}${prefix}$path"
} }
}
suspend fun getProfilesAndFolders(type: SeerrItemType): SeerrRequestData {
var result = SeerrRequestData()
val current = seerrServerRepository.current.firstOrNull()
if (current != null && current.config.hasPermission(SeerrPermission.REQUEST_ADVANCED)) {
if (type == SeerrItemType.MOVIE) {
val radarrs = api.serviceApi.serviceRadarrGet()
val radarr =
radarrs.firstOrNull { it.isDefault && !it.is4k }
?: radarrs.firstOrNull { !it.is4k }
val radarr4k =
radarrs.firstOrNull { it.isDefault && it.is4k }
?: radarrs.firstOrNull { it.is4k }
if (radarr != null && radarr.id != null) {
val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr.id)
setProfilesAndFolder(
settings.profiles,
settings.rootFolders,
settings.server?.activeProfileId,
settings.server?.activeDirectory,
) { profiles, rootFolders ->
result =
result.copy(
serverId = radarr.id,
profiles = profiles,
rootFolders = rootFolders,
)
}
}
if (radarr4k != null && radarr4k.id != null) {
val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr4k.id)
setProfilesAndFolder(
settings.profiles,
settings.rootFolders,
settings.server?.activeProfileId,
settings.server?.activeDirectory,
) { profiles, rootFolders ->
result =
result.copy(
server4kId = radarr4k.id,
profiles4k = profiles,
rootFolders4k = rootFolders,
)
}
}
} else {
val sonarrs = api.serviceApi.serviceSonarrGet()
val sonarr =
sonarrs.firstOrNull { it.isDefault && !it.is4k }
?: sonarrs.firstOrNull { !it.is4k }
val sonarr4k =
sonarrs.firstOrNull { it.isDefault && it.is4k }
?: sonarrs.firstOrNull { it.is4k }
if (sonarr != null && sonarr.id != null) {
val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr.id)
setProfilesAndFolder(
settings.profiles,
settings.rootFolders,
settings.server?.activeProfileId,
settings.server?.activeDirectory,
) { profiles, rootFolders ->
result =
result.copy(
serverId = sonarr.id,
profiles = profiles,
rootFolders = rootFolders,
)
}
}
if (sonarr4k != null && sonarr4k.id != null) {
val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr4k.id)
setProfilesAndFolder(
settings.profiles,
settings.rootFolders,
settings.server?.activeProfileId,
settings.server?.activeDirectory,
) { profiles, rootFolders ->
result =
result.copy(
server4kId = sonarr4k.id,
profiles4k = profiles,
rootFolders4k = rootFolders,
)
}
}
}
}
return result
}
private fun setProfilesAndFolder(
profiles: List<ServiceProfile>?,
rootFolders: List<RootFolder>?,
activeProfileId: Int?,
activeDirectory: String?,
setter: (List<SeerrProfile>, List<SeerrRootFolder>) -> Unit,
) {
val profiles =
profiles.orEmpty().mapNotNull {
if (it.id != null && it.name != null) {
SeerrProfile(it.id, it.name, activeProfileId == it.id)
} else {
null
}
}
val rootFolders =
rootFolders.orEmpty().mapNotNull {
if (it.id != null && it.path != null) {
val freeSpace = it.freeSpace?.let { formatBytes(it) } ?: ""
SeerrRootFolder(it.id, it.path, freeSpace, activeDirectory == it.path)
} else {
null
}
}
setter.invoke(profiles, rootFolders)
}
suspend fun createDiscoverItem(item: Any): DiscoverItem = suspend fun createDiscoverItem(item: Any): DiscoverItem =
when (item) { when (item) {
@ -372,8 +216,7 @@ class SeerrService
releaseDate = toLocalDate(movie.releaseDate), releaseDate = toLocalDate(movie.releaseDate),
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo), jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
jellyfinItemId = movie.mediaInfo?.jellyfinId,
) )
suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem = suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem =
@ -389,8 +232,7 @@ class SeerrService
releaseDate = toLocalDate(movie.releaseDate), releaseDate = toLocalDate(movie.releaseDate),
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo), jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
jellyfinItemId = movie.mediaInfo?.jellyfinId,
) )
suspend fun createDiscoverItem(tv: TvResult): DiscoverItem = suspend fun createDiscoverItem(tv: TvResult): DiscoverItem =
@ -406,8 +248,7 @@ class SeerrService
releaseDate = toLocalDate(tv.firstAirDate), releaseDate = toLocalDate(tv.firstAirDate),
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo), jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
jellyfinItemId = tv.mediaInfo?.jellyfinId,
) )
suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem = suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem =
@ -423,8 +264,7 @@ class SeerrService
releaseDate = toLocalDate(tv.firstAirDate), releaseDate = toLocalDate(tv.firstAirDate),
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo), jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
jellyfinItemId = tv.mediaInfo?.jellyfinId,
) )
suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem = suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem =
@ -440,8 +280,7 @@ class SeerrService
releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate), releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate),
posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo), posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo),
logoUrl = createImageUrl(ImageType.LOGO, null, search.mediaInfo), jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
jellyfinItemId = search.mediaInfo?.jellyfinId,
) )
suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem = suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem =
@ -454,7 +293,7 @@ class SeerrService
availability = availability =
SeerrAvailability.from(credit.mediaInfo?.status) SeerrAvailability.from(credit.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN, ?: SeerrAvailability.UNKNOWN,
releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate), releaseDate = toLocalDate(credit.firstAirDate),
posterUrl = posterUrl =
createImageUrl( createImageUrl(
ImageType.PRIMARY, ImageType.PRIMARY,
@ -467,8 +306,7 @@ class SeerrService
credit.backdropPath, credit.backdropPath,
credit.mediaInfo, credit.mediaInfo,
), ),
logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo), jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
jellyfinItemId = credit.mediaInfo?.jellyfinId,
) )
suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem = suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem =
@ -481,7 +319,7 @@ class SeerrService
availability = availability =
SeerrAvailability.from(credit.mediaInfo?.status) SeerrAvailability.from(credit.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN, ?: SeerrAvailability.UNKNOWN,
releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate), releaseDate = toLocalDate(credit.firstAirDate),
posterUrl = posterUrl =
createImageUrl( createImageUrl(
ImageType.PRIMARY, ImageType.PRIMARY,
@ -494,13 +332,6 @@ class SeerrService
credit.backdropPath, credit.backdropPath,
credit.mediaInfo, credit.mediaInfo,
), ),
logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo), jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
jellyfinItemId = credit.mediaInfo?.jellyfinId,
) )
} }
val MediaInfo.jellyfinId: UUID?
get() = jellyfinMediaId4k?.toUUIDOrNull() ?: jellyfinMediaId?.toUUIDOrNull()
val MediaInfo.jellyfinIdAsString: String?
get() = jellyfinMediaId4k ?: jellyfinMediaId

View file

@ -15,7 +15,6 @@ import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -95,8 +94,6 @@ class ServerEventListener
.joinToString("\n") .joinToString("\n")
showToast(context, toast, Toast.LENGTH_LONG) showToast(context, toast, Toast.LENGTH_LONG)
} }
}.catch { ex ->
Timber.e(ex, "Error in websocket subscription")
}.launchIn(activity.lifecycleScope) }.launchIn(activity.lifecycleScope)
} }

View file

@ -27,6 +27,9 @@ class SetupNavigationManager
fun navigateTo(destination: SetupDestination) { fun navigateTo(destination: SetupDestination) {
backStack[0] = destination backStack[0] = destination
log() log()
if (destination !is SetupDestination.AppContent) {
navigationManager.reloadHome()
}
} }
private fun log() { private fun log() {

View file

@ -5,9 +5,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.UserProfileSettings
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
@ -137,8 +135,8 @@ class StreamChoiceService
val seriesLang = val seriesLang =
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() } playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
// If the user has chosen a different language for the series, prefer that // If the user has chosen a different language for the series, prefer that
val audioLanguage = val audioLanguage = seriesLang ?: userConfig?.audioLanguagePreference
seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
if (audioLanguage.isNotNullOrBlank()) { if (audioLanguage.isNotNullOrBlank()) {
val sorted = val sorted =
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels }) candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
@ -228,17 +226,23 @@ class StreamChoiceService
): MediaStream? { ): MediaStream? {
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
return null return null
} } else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
// Client-side manual override: User selected "Only Forced" in player menu
val seriesLang = val seriesLang =
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
val subtitleLanguage = val subtitleLanguage =
seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) (seriesLang ?: userConfig?.subtitleLanguagePreference)
if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { ?.takeIf { it.isNotNullOrBlank() }
// Client-side manual override: User selected "Only Forced" in player menu
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang) return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
} else if (itemPlayback?.subtitleIndexEnabled == true) { } else if (itemPlayback?.subtitleIndexEnabled == true) {
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else { } else {
val seriesLang =
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
val subtitleLanguage =
(seriesLang ?: userConfig?.subtitleLanguagePreference)
?.takeIf { it.isNotNullOrBlank() }
val subtitleMode = val subtitleMode =
when { when {
playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> { playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
@ -254,17 +258,9 @@ class StreamChoiceService
else -> { else -> {
// Fallback to the user's preference // Fallback to the user's preference
when (prefs.userPreferences?.subtitleMode) { userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT
SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
null -> SubtitlePlaybackMode.DEFAULT
} }
} }
} ?: SubtitlePlaybackMode.DEFAULT
val candidates = val candidates =
candidates candidates
.sortedWith( .sortedWith(
@ -281,13 +277,11 @@ class StreamChoiceService
SubtitlePlaybackMode.ALWAYS -> { SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage.isNotNullOrBlank()) { if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull { candidates.firstOrNull {
// Prefer non-forced first it.language.equals(subtitleLanguage, true) ||
!it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage) it.language.isUnknown
} ?: candidates.firstOrNull {
it.language.equalsLangOrUnknown(subtitleLanguage)
} }
} else { } else {
candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull() candidates.firstOrNull()
} }
} }
@ -377,38 +371,3 @@ private val String?.isUnknown: Boolean
this.equals("undetermined", true) || this.equals("undetermined", true) ||
this.equals("mul", true) || this.equals("mul", true) ||
this.equals("zxx", true) this.equals("zxx", true)
private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown
/**
* Based on the user's preferences, get their preferred language for audio or subtitles
*
* @param prefs the [UserPreferences]
*/
fun getPreferredLanguage(
type: MediaStreamType,
prefs: UserPreferences,
userConfig: UserConfiguration?,
): String? {
val (pref, profileLang) =
when (type) {
MediaStreamType.AUDIO -> {
prefs.userPreferences?.preferredAudioLanguage to
userConfig?.audioLanguagePreference
}
MediaStreamType.SUBTITLE -> {
prefs.userPreferences?.preferredSubtitleLanguage to
userConfig?.subtitleLanguagePreference
}
else -> {
throw IllegalArgumentException("Only audio or subtitle supported, not $type")
}
}
return when (pref) {
UserProfileSettings.USE_USER_PROFILE -> profileLang
UserProfileSettings.PREFER_ANY_LANGUAGE -> null
else -> pref
}?.takeIf { it.isNotNullOrBlank() }
}

View file

@ -4,11 +4,11 @@ package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.mayakapps.kache.InMemoryKache import com.mayakapps.kache.InMemoryKache
import com.mayakapps.kache.ObjectKache import com.mayakapps.kache.ObjectKache
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
@ -43,7 +43,7 @@ class SuggestionsCache
private val memoryCache = private val memoryCache =
InMemoryKache<String, CachedSuggestions>(maxSize = 8) { InMemoryKache<String, CachedSuggestions>(maxSize = 8) {
creationScope = CoroutineScope(WholphinDispatchers.IO) creationScope = CoroutineScope(Dispatchers.IO)
} }
private fun cacheKey( private fun cacheKey(

View file

@ -9,9 +9,9 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@ -81,7 +81,7 @@ class SuggestionsWorker
.takeIf { it > 0 } .takeIf { it > 0 }
?: AppPreference.HomePageItems.defaultValue.toInt() ?: AppPreference.HomePageItems.defaultValue.toInt()
val context = WholphinDispatchers.IO.limitedParallelism(2, "fetchSuggestions") val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
if (requestedParentId != null && requestedItemKind != null) { if (requestedParentId != null && requestedItemKind != null) {
Timber.d( Timber.d(
"Fetching on-demand suggestions for parent=%s kind=%s", "Fetching on-demand suggestions for parent=%s kind=%s",
@ -128,7 +128,7 @@ class SuggestionsWorker
supervisorScope { supervisorScope {
supportedViews supportedViews
.map { view -> .map { view ->
async(WholphinDispatchers.IO) { async(Dispatchers.IO) {
runCatching { runCatching {
Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind) Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind)
fetchAndCacheSuggestions( fetchAndCacheSuggestions(
@ -293,7 +293,7 @@ class SuggestionsWorker
limit = freshLimit, limit = freshLimit,
) )
} }
withContext(WholphinDispatchers.Default) { withContext(Dispatchers.Default) {
val contextual = contextualDeferred.await() val contextual = contextualDeferred.await()
val random = randomDeferred.await() val random = randomDeferred.await()
val fresh = freshDeferred.await() val fresh = freshDeferred.await()

View file

@ -14,10 +14,10 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.profile.Codec
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -102,7 +102,7 @@ class ThemeSongPlayer
Codec.Audio.FLAC, Codec.Audio.FLAC,
), ),
) )
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
player.apply { player.apply {
stop() stop()
volume = volumeLevel volume = volumeLevel
@ -127,7 +127,7 @@ class ThemeSongPlayer
scope.launch { scope.launch {
mutex.withLock { mutex.withLock {
state.value.job?.cancelAndJoin() state.value.job?.cancelAndJoin()
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
Timber.v("Stopping theme song") Timber.v("Stopping theme song")
player.stop() player.stop()
} }

View file

@ -34,7 +34,7 @@ class TrailerService
// TODO would be nice to clean up the trailer name // TODO would be nice to clean up the trailer name
// ?.replace(item.name ?: "", "") // ?.replace(item.name ?: "", "")
// ?.removePrefix(" - ") // ?.removePrefix(" - ")
?: context.resources.getQuantityString(R.plurals.trailers, 1) ?: context.getString(R.string.trailer)
val subtitle = val subtitle =
when (url.toUri().host) { when (url.toUri().host) {
"youtube.com", "www.youtube.com" -> "YouTube" "youtube.com", "www.youtube.com" -> "YouTube"

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -22,8 +21,8 @@ import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.Version import com.github.damontecres.wholphin.util.Version
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -62,6 +61,8 @@ class UpdateChecker
private const val APK_MIME_TYPE = "application/vnd.android.package-archive" private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
private const val PERMISSION_REQUEST_CODE = 123456
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->") private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
val ACTIVE = BuildConfig.UPDATING_ENABLED val ACTIVE = BuildConfig.UPDATING_ENABLED
@ -130,7 +131,7 @@ class UpdateChecker
suspend fun getRelease(version: Version): Release? { suspend fun getRelease(version: Version): Release? {
val url = val url =
"https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}" "https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}"
return withContext(WholphinDispatchers.IO) { return withContext(Dispatchers.IO) {
val request = val request =
Request Request
.Builder() .Builder()
@ -145,7 +146,7 @@ class UpdateChecker
* Get the latest released version * Get the latest released version
*/ */
suspend fun getLatestRelease(updateUrl: String): Release? = suspend fun getLatestRelease(updateUrl: String): Release? =
withContext(WholphinDispatchers.IO) { withContext(Dispatchers.IO) {
val request = val request =
Request Request
.Builder() .Builder()
@ -157,8 +158,8 @@ class UpdateChecker
private fun getRelease(request: Request): Release? { private fun getRelease(request: Request): Release? {
return okHttpClient.newCall(request).execute().use { return okHttpClient.newCall(request).execute().use {
if (it.isSuccessful) { if (it.isSuccessful && it.body != null) {
val result = Json.parseToJsonElement(it.body.string()) val result = Json.parseToJsonElement(it.body!!.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name) val version = Version.tryFromString(name)
val publishedAt = val publishedAt =
@ -194,12 +195,11 @@ class UpdateChecker
/** /**
* Download and install an update * Download and install an update
*/ */
@SuppressLint("RequestInstallPackagesPolicy")
suspend fun installRelease( suspend fun installRelease(
release: Release, release: Release,
callback: DownloadCallback, callback: DownloadCallback,
) { ) {
withContext(WholphinDispatchers.IO) { withContext(Dispatchers.IO) {
cleanup() cleanup()
val request = val request =
Request Request
@ -208,9 +208,9 @@ class UpdateChecker
.get() .get()
.build() .build()
okHttpClient.newCall(request).execute().use { okHttpClient.newCall(request).execute().use {
if (it.isSuccessful) { if (it.isSuccessful && it.body != null) {
Timber.v("Request successful for ${release.downloadUrl}") Timber.v("Request successful for ${release.downloadUrl}")
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
callback.contentLength(it.body.contentLength()) callback.contentLength(it.body.contentLength())
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -230,7 +230,7 @@ class UpdateChecker
contentValues, contentValues,
) )
if (uri != null) { if (uri != null) {
it.body.byteStream().use { input -> it.body!!.byteStream().use { input ->
resolver.openOutputStream(uri).use { output -> resolver.openOutputStream(uri).use { output ->
copyTo(input, output!!, callback = callback) copyTo(input, output!!, callback = callback)
} }
@ -290,7 +290,7 @@ class UpdateChecker
downloadDir.mkdirs() downloadDir.mkdirs()
val targetFile = File(downloadDir, APK_NAME) val targetFile = File(downloadDir, APK_NAME)
targetFile.outputStream().use { output -> targetFile.outputStream().use { output ->
response.body.byteStream().use { input -> response.body!!.byteStream().use { input ->
copyTo(input, output, callback = callback) copyTo(input, output, callback = callback)
} }
} }
@ -374,7 +374,7 @@ data class Release(
Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"), Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"),
"#$1", "#$1",
) )
// Remove the last line for full changelog since it's just a link // Remove the last line for full changelog since its just a link
.replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "") .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "")
} }
@ -387,30 +387,29 @@ interface DownloadCallback {
suspend fun copyTo( suspend fun copyTo(
input: InputStream, input: InputStream,
out: OutputStream, out: OutputStream,
bufferSize: Int = 64 * 1024, bufferSize: Int = 16 * 1024,
callback: DownloadCallback, callback: DownloadCallback,
): Long = ): Long {
withContext(WholphinDispatchers.IO) {
var bytesCopied: Long = 0 var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize) val buffer = ByteArray(bufferSize)
var bytes = input.read(buffer) var bytes = input.read(buffer)
while (bytes >= 0) { while (bytes >= 0) {
out.write(buffer, 0, bytes) out.write(buffer, 0, bytes)
bytesCopied += bytes bytesCopied += bytes
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
callback.bytesDownloaded(bytesCopied) callback.bytesDownloaded(bytesCopied)
} }
bytes = input.read(buffer) bytes = input.read(buffer)
} }
return@withContext bytesCopied return bytesCopied
} }
fun getDownloadUrl( fun getDownloadUrl(
assets: JsonArray, assets: JsonArray,
debug: Boolean, debug: Boolean,
supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList(), supportedAbis: List<String> = Build.SUPPORTED_ABIS.toList(),
): String? { ): String? {
val abiSuffix = supportedABIs.firstOrNull().let { if (it != null) "-$it" else "" } val abiSuffix = supportedAbis.firstOrNull().let { if (it != null) "-$it" else "" }
val releaseSuffix = if (debug) "-debug" else "-release" val releaseSuffix = if (debug) "-debug" else "-release"
val preferredNames = val preferredNames =
buildList { buildList {

View file

@ -4,9 +4,8 @@ import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.first
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -20,10 +19,13 @@ class UserPreferencesService
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>, private val preferencesDataStore: DataStore<AppPreferences>,
) { ) {
val flow: Flow<UserPreferences> = val flow = preferencesDataStore.data.map { UserPreferences(it) }
preferencesDataStore.data.combine(serverRepository.currentUserFlow) { appPrefs, user ->
UserPreferences(appPrefs, user?.appPreferences)
}
suspend fun getCurrent(): UserPreferences = flow.first() suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto!!.configuration.let { userConfig ->
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
UserPreferences(
appPrefs,
)
}
} }

View file

@ -12,9 +12,9 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -56,7 +56,7 @@ class UserSwitchListener
user.uiLanguage?.let { LocaleListCompat.forLanguageTags(it) } user.uiLanguage?.let { LocaleListCompat.forLanguageTags(it) }
?: LocaleListCompat.getEmptyLocaleList() ?: LocaleListCompat.getEmptyLocaleList()
Timber.i("Switching locale to %s", localeList) Timber.i("Switching locale to %s", localeList)
withContext(WholphinDispatchers.Main) { withContext(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(localeList) AppCompatDelegate.setApplicationLocales(localeList)
} }

View file

@ -1,13 +1,18 @@
package com.github.damontecres.wholphin.services.hilt package com.github.damontecres.wholphin.services.hilt
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore
import androidx.work.WorkManager import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -15,7 +20,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.android.androidDevice import org.jellyfin.sdk.android.androidDevice
@ -42,21 +49,21 @@ annotation class AuthOkHttpClient
annotation class StandardOkHttpClient annotation class StandardOkHttpClient
/** /**
* A [CoroutineScope] with [WholphinDispatchers.IO] * A [CoroutineScope] with [Dispatchers.IO]
*/ */
@Qualifier @Qualifier
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class IoCoroutineScope annotation class IoCoroutineScope
/** /**
* A [CoroutineScope] with [WholphinDispatchers.Default] * A [CoroutineScope] with [Dispatchers.Default]
*/ */
@Qualifier @Qualifier
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class DefaultCoroutineScope annotation class DefaultCoroutineScope
/** /**
* [WholphinDispatchers.IO] * [Dispatchers.IO]
* *
* @see IoCoroutineScope * @see IoCoroutineScope
*/ */
@ -65,7 +72,7 @@ annotation class DefaultCoroutineScope
annotation class IoDispatcher annotation class IoDispatcher
/** /**
* [WholphinDispatchers.Default] * [Dispatchers.Default]
* *
* @see DefaultCoroutineScope * @see DefaultCoroutineScope
*/ */
@ -153,10 +160,55 @@ object AppModule {
@Singleton @Singleton
fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi() fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi()
/**
* Implementation of [RememberTabManager] which remembers by server, user, & item
*/
@Provides
@Singleton
fun rememberTabManager(
serverRepository: ServerRepository,
appPreference: DataStore<AppPreferences>,
@IoCoroutineScope scope: CoroutineScope,
) = object : RememberTabManager {
fun key(itemId: String): String =
serverRepository.current.value.let {
"${it?.server?.id}_${it?.user?.id}_$itemId"
}
override fun getRememberedTab(
preferences: UserPreferences,
itemId: String,
defaultTab: Int,
): Int {
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
return preferences.appPreferences.interfacePreferences
.getRememberedTabsOrDefault(key(itemId), defaultTab)
} else {
return defaultTab
}
}
override fun saveRememberedTab(
preferences: UserPreferences,
itemId: String,
tabIndex: Int,
) {
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
scope.launch(ExceptionHandler()) {
appPreference.updateData {
it.updateInterfacePreferences {
putRememberedTabs(key(itemId), tabIndex)
}
}
}
}
}
}
@Provides @Provides
@Singleton @Singleton
@IoDispatcher @IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = WholphinDispatchers.IO fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides @Provides
@Singleton @Singleton
@ -168,7 +220,7 @@ object AppModule {
@Provides @Provides
@Singleton @Singleton
@DefaultDispatcher @DefaultDispatcher
fun defaultDispatcher(): CoroutineDispatcher = WholphinDispatchers.Default fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@Provides @Provides
@Singleton @Singleton

View file

@ -16,7 +16,6 @@ import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
@ -44,7 +43,7 @@ object DatabaseModule {
).addMigrations(Migrations.Migrate2to3) ).addMigrations(Migrations.Migrate2to3)
// .setQueryCallback({ sqlQuery, args -> // .setQueryCallback({ sqlQuery, args ->
// Timber.v("sqlQuery=$sqlQuery, args=$args") // Timber.v("sqlQuery=$sqlQuery, args=$args")
// }, WholphinDispatchers.IO.asExecutor()) // }, Dispatchers.IO.asExecutor())
.build() .build()
@Provides @Provides
@ -75,10 +74,6 @@ object DatabaseModule {
@Singleton @Singleton
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
@Provides
@Singleton
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
@Provides @Provides
@Singleton @Singleton
fun userPreferencesDataStore( fun userPreferencesDataStore(

View file

@ -24,7 +24,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -227,27 +226,27 @@ class TvProviderWorker
setIntent( setIntent(
Intent(context, MainActivity::class.java) Intent(context, MainActivity::class.java)
.putExtra(IntentService.INTENT_ITEM_ID, item.id.toString()) .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
.putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName) .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
.apply { .apply {
if (item.type == BaseItemKind.EPISODE) { if (item.type == BaseItemKind.EPISODE) {
putExtra( putExtra(
IntentService.INTENT_SERIES_ID, MainActivity.INTENT_SERIES_ID,
dto.seriesId?.toString(), dto.seriesId?.toString(),
) )
putExtra( putExtra(
IntentService.INTENT_SEASON_ID, MainActivity.INTENT_SEASON_ID,
dto.seasonId?.toString(), dto.seasonId?.toString(),
) )
dto.parentIndexNumber?.let { dto.parentIndexNumber?.let {
putExtra( putExtra(
IntentService.INTENT_SEASON_NUMBER, MainActivity.INTENT_SEASON_NUMBER,
it, it,
) )
} }
dto.indexNumber?.let { dto.indexNumber?.let {
putExtra( putExtra(
IntentService.INTENT_EPISODE_NUMBER, MainActivity.INTENT_EPISODE_NUMBER,
it, it,
) )
} }
@ -265,7 +264,6 @@ class TvProviderWorker
.getLatestMedia( .getLatestMedia(
GetLatestMediaRequest( GetLatestMediaRequest(
fields = SlimItemFields, fields = SlimItemFields,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
imageTypeLimit = 1, imageTypeLimit = 1,
parentId = null, parentId = null,
groupItems = true, groupItems = true,
@ -364,27 +362,27 @@ class TvProviderWorker
setIntent( setIntent(
Intent(context, MainActivity::class.java) Intent(context, MainActivity::class.java)
.putExtra(IntentService.INTENT_ITEM_ID, item.id.toString()) .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
.putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName) .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
.apply { .apply {
if (item.type == BaseItemKind.EPISODE) { if (item.type == BaseItemKind.EPISODE) {
putExtra( putExtra(
IntentService.INTENT_SERIES_ID, MainActivity.INTENT_SERIES_ID,
dto.seriesId?.toString(), dto.seriesId?.toString(),
) )
putExtra( putExtra(
IntentService.INTENT_SEASON_ID, MainActivity.INTENT_SEASON_ID,
dto.seasonId?.toString(), dto.seasonId?.toString(),
) )
dto.parentIndexNumber?.let { dto.parentIndexNumber?.let {
putExtra( putExtra(
IntentService.INTENT_SEASON_NUMBER, MainActivity.INTENT_SEASON_NUMBER,
it, it,
) )
} }
dto.indexNumber?.let { dto.indexNumber?.let {
putExtra( putExtra(
IntentService.INTENT_EPISODE_NUMBER, MainActivity.INTENT_EPISODE_NUMBER,
it, it,
) )
} }

View file

@ -9,7 +9,6 @@ import coil3.disk.DiskCache
import coil3.disk.directory import coil3.disk.directory
import coil3.memory.MemoryCache import coil3.memory.MemoryCache
import coil3.network.CacheStrategy import coil3.network.CacheStrategy
import coil3.network.ConnectivityChecker
import coil3.network.NetworkRequest import coil3.network.NetworkRequest
import coil3.network.NetworkResponse import coil3.network.NetworkResponse
import coil3.network.cachecontrol.CacheControlCacheStrategy import coil3.network.cachecontrol.CacheControlCacheStrategy
@ -96,8 +95,6 @@ fun CoilConfig(
OkHttpNetworkFetcherFactory( OkHttpNetworkFetcherFactory(
cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) }, cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) },
callFactory = { client }, callFactory = { client },
// Disable connectivity checker which may be unreliable for VPNs and/or some versions of Android
connectivityChecker = { ConnectivityChecker { true } },
), ),
) )
}.build() }.build()

View file

@ -33,9 +33,9 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.data.RowColumnSaver import com.github.damontecres.wholphin.ui.data.RowColumnSaver
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@ -74,8 +74,6 @@ inline fun <T> List<T>.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? {
return if (index >= 0) index else null return if (index >= 0) index else null
} }
fun Iterable<CharSequence?>.joinNotBlank(sep: String): String? = filter { it.isNotNullOrBlank() }.letNotEmpty { it.joinToString(sep) }
/** /**
* Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly * Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly
*/ */
@ -244,7 +242,7 @@ val BaseItemDto.timeRemaining: Duration?
get() = get() =
userData?.playbackPositionTicks?.let { userData?.playbackPositionTicks?.let {
if (it > 0) { if (it > 0) {
runTimeTicks?.minus(it)?.ticks?.takeIf { it > Duration.ZERO } runTimeTicks?.minus(it)?.ticks
} else { } else {
null null
} }
@ -361,34 +359,34 @@ suspend fun showToast(
context: Context, context: Context,
text: CharSequence, text: CharSequence,
duration: Int, duration: Int,
) = withContext(WholphinDispatchers.Main) { ) = withContext(Dispatchers.Main) {
Toast.makeText(context, text, duration).show() Toast.makeText(context, text, duration).show()
} }
suspend fun showToast( suspend fun showToast(
context: Context, context: Context,
text: CharSequence, text: CharSequence,
) = withContext(WholphinDispatchers.Main) { ) = withContext(Dispatchers.Main) {
Toast.makeText(context, text, Toast.LENGTH_LONG).show() Toast.makeText(context, text, Toast.LENGTH_LONG).show()
} }
/** /**
* Launches a coroutine with [WholphinDispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] * Launches a coroutine with [Dispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
*/ */
fun CoroutineScope.launchIO( fun CoroutineScope.launchIO(
context: CoroutineContext = ExceptionHandler(), context: CoroutineContext = ExceptionHandler(),
start: CoroutineStart = CoroutineStart.DEFAULT, start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit, block: suspend CoroutineScope.() -> Unit,
): Job = launch(context = WholphinDispatchers.IO + context, start = start, block = block) ): Job = launch(context = Dispatchers.IO + context, start = start, block = block)
/** /**
* Launches a coroutine with [WholphinDispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] * Launches a coroutine with [Dispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
*/ */
fun CoroutineScope.launchDefault( fun CoroutineScope.launchDefault(
context: CoroutineContext = ExceptionHandler(), context: CoroutineContext = ExceptionHandler(),
start: CoroutineStart = CoroutineStart.DEFAULT, start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit, block: suspend CoroutineScope.() -> Unit,
): Job = launch(context = WholphinDispatchers.Default + context, start = start, block = block) ): Job = launch(context = Dispatchers.Default + context, start = start, block = block)
/** /**
* Converts a UUID to the format used server-side (ie without hyphens). * Converts a UUID to the format used server-side (ie without hyphens).
@ -412,7 +410,7 @@ fun logTab(
ACRA.errorReporter.putCustomData("tabInfo", info) ACRA.errorReporter.putCustomData("tabInfo", info)
} }
suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(WholphinDispatchers.Main, block) suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
fun Modifier.dimAndBlur(enabled: Boolean) = fun Modifier.dimAndBlur(enabled: Boolean) =
this.ifElse( this.ifElse(
@ -427,9 +425,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
useSeriesForPrimary: Boolean, useSeriesForPrimary: Boolean,
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } ) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
fun Response<BaseItemDtoQueryResult>.toBaseItems(useSeriesForPrimary: Boolean = false) =
this.content.items.map { BaseItem(it, useSeriesForPrimary = useSeriesForPrimary) }
/** /**
* Check if this, coalescing nulls to zero, is greater than that * Check if this, coalescing nulls to zero, is greater than that
*/ */
@ -451,15 +446,3 @@ fun <T> Flow<T>.collectLatestIn(
* Easy way to combine two flows into a [Pair] * Easy way to combine two flows into a [Pair]
*/ */
fun <T1, T2> Flow<T1>.combinePair(flow: Flow<T2>): Flow<Pair<T1, T2>> = combine(flow) { t1, t2 -> Pair(t1, t2) } fun <T1, T2> Flow<T1>.combinePair(flow: Flow<T2>): Flow<Pair<T1, T2>> = combine(flow) { t1, t2 -> Pair(t1, t2) }
/**
* Easy way to combine three flows into a [Triple]
*/
fun <T1, T2, T3> Flow<T1>.combineTriple(
flow: Flow<T2>,
flow2: Flow<T3>,
): Flow<Triple<T1, T2, T3>> =
combine(flow) { t1, t2 -> Pair(t1, t2) }
.combine(flow2) { (t1, t2), t3 ->
Triple(t1, t2, t3)
}

View file

@ -1,8 +1,5 @@
package com.github.damontecres.wholphin.ui package com.github.damontecres.wholphin.ui
import android.content.Context
import android.content.res.Resources
import android.text.format.DateFormat
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@ -15,36 +12,19 @@ import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber import timber.log.Timber
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException import java.time.format.DateTimeParseException
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.time.Duration
/** private var timeFormatter: DateTimeFormatter =
* Format the time-of-day of [time] honouring the device's 12/24-hour setting and the current DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault())
* locale. [context] should be an Activity/composition context (e.g. `LocalContext.current`) so an
* in-app language switch (`AppCompatDelegate.setApplicationLocales`) is reflected; the application
* context may carry a stale locale.
*/
fun formatTime(
context: Context,
time: LocalDateTime,
): String = formatTime(context, time.toLocalTime())
fun formatTime( fun getTimeFormatter(): DateTimeFormatter {
context: Context, if (timeFormatter.locale != Locale.getDefault()) {
time: LocalTime, timeFormatter = timeFormatter.withLocale(Locale.getDefault())
): String { }
val instant = return timeFormatter
time
.atDate(LocalDate.now())
.atZone(ZoneId.systemDefault())
.toInstant()
return DateFormat.getTimeFormat(context).format(Date.from(instant))
} }
private var dateFormatter: DateTimeFormatter = private var dateFormatter: DateTimeFormatter =
@ -79,74 +59,36 @@ fun toLocalDate(date: String?): LocalDate? =
} }
} }
// Falls back to the default format if the application is not initialized (e.g. unit tests)
private fun formatStringWithFallback(
@StringRes resId: Int,
defaultFormat: String,
vararg args: Any,
): String {
val resources =
try {
WholphinApplication.instance.resources
} catch (_: UninitializedPropertyAccessException) {
null
}
return resources?.getString(resId, *args)
?: String.format(Locale.getDefault(), defaultFormat, *args)
}
/** /**
* Format season & episode numbers using localized string resources. * If the item has season & episode info, format as `S# E#`
* Returns null if [season] or [episode] is missing.
*/
fun formatSeasonEpisode(
season: Int?,
episode: Int?,
episodeEnd: Int? = null,
padded: Boolean = false,
): String? {
if (season == null || episode == null) return null
return if (episodeEnd != null) {
if (padded) {
formatStringWithFallback(R.string.season_episode_number_padded_range, "S%1\$02dE%2\$02d-E%3\$02d", season, episode, episodeEnd)
} else {
formatStringWithFallback(R.string.season_episode_number_range, "S%1\$d E%2\$d-E%3\$d", season, episode, episodeEnd)
}
} else if (padded) {
formatStringWithFallback(R.string.season_episode_number_padded, "S%1\$02dE%2\$02d", season, episode)
} else {
formatStringWithFallback(R.string.season_episode_number, "S%1\$d E%2\$d", season, episode)
}
}
/** Compact episode-only label, e.g. `E3`. */
fun formatEpisodeNumber(episode: Int): String = formatStringWithFallback(R.string.episode_number_short, "E%1\$d", episode)
/** Fallback season title when the library has no season name, e.g. `Season 2`. */
fun formatSeasonNumber(season: Int): String = formatStringWithFallback(R.string.season_number, "Season %1\$d", season)
/**
* If the item has season & episode info, format as localized `S# E#`
*/ */
val BaseItemDto.seasonEpisode: String? val BaseItemDto.seasonEpisode: String?
get() = get() =
formatSeasonEpisode( if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) {
parentIndexNumber, "S$parentIndexNumber E$indexNumber-E$indexNumberEnd"
indexNumber, } else if (parentIndexNumber != null && indexNumber != null) {
indexNumberEnd, "S$parentIndexNumber E$indexNumber"
) } else {
null
}
/** /**
* If the item has season & episode info, format padded as localized `S##E##` * If the item has season & episode info, format padded as `S## E##`
*/ */
val BaseItemDto.seasonEpisodePadded: String? val BaseItemDto.seasonEpisodePadded: String?
get() = get() =
formatSeasonEpisode( if (parentIndexNumber != null && indexNumber != null) {
parentIndexNumber, val season = parentIndexNumber?.toString()?.padStart(2, '0')
indexNumber, val episode = indexNumber?.toString()?.padStart(2, '0')
indexNumberEnd, val endEpisode = indexNumberEnd?.toString()?.padStart(2, '0')
padded = true, if (endEpisode != null) {
) "S${season}E$episode-E$endEpisode"
} else {
"S${season}E$episode"
}
} else {
null
}
val BaseItemDto.seriesProductionYears: String? val BaseItemDto.seriesProductionYears: String?
get() = get() =
@ -258,64 +200,44 @@ fun listToDotString(
} }
} }
@get:StringRes
val BaseItemKind.titleStringRes: Int
get() = formatTypeName(this)
@StringRes @StringRes
fun formatTypeName(type: BaseItemKind): Int = fun formatTypeName(type: BaseItemKind): Int =
when (type) { when (type) {
BaseItemKind.MOVIE -> R.string.movies_title BaseItemKind.MOVIE -> R.string.movies
BaseItemKind.SERIES -> R.string.tv_shows_title BaseItemKind.SERIES -> R.string.tv_shows
BaseItemKind.EPISODE -> R.string.episodes BaseItemKind.EPISODE -> R.string.episodes
BaseItemKind.VIDEO -> R.string.videos BaseItemKind.VIDEO -> R.string.videos
BaseItemKind.PLAYLIST -> R.string.playlists BaseItemKind.PLAYLIST -> R.string.playlists
BaseItemKind.PERSON -> R.string.people_title BaseItemKind.PERSON -> R.string.people
BaseItemKind.BOX_SET -> R.string.collections BaseItemKind.BOX_SET -> R.string.collections
BaseItemKind.AUDIO -> R.string.songs BaseItemKind.AUDIO -> TODO()
BaseItemKind.CHANNEL -> R.string.channels BaseItemKind.CHANNEL -> R.string.channels
BaseItemKind.GENRE -> R.string.genres BaseItemKind.GENRE -> R.string.genres
BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels
BaseItemKind.MUSIC_ALBUM -> R.string.albums BaseItemKind.MUSIC_ALBUM -> TODO()
BaseItemKind.MUSIC_ARTIST -> R.string.artists BaseItemKind.MUSIC_ARTIST -> TODO()
BaseItemKind.MUSIC_GENRE -> R.string.genres BaseItemKind.MUSIC_GENRE -> TODO()
BaseItemKind.MUSIC_VIDEO -> R.string.music_videos BaseItemKind.MUSIC_VIDEO -> TODO()
BaseItemKind.PHOTO -> R.string.photos BaseItemKind.PHOTO -> R.string.photos
BaseItemKind.PHOTO_ALBUM -> R.string.photo_albums BaseItemKind.PHOTO_ALBUM -> TODO()
BaseItemKind.PROGRAM -> R.string.programs BaseItemKind.PROGRAM -> TODO()
BaseItemKind.RECORDING -> R.string.recordings BaseItemKind.RECORDING -> TODO()
BaseItemKind.SEASON -> R.string.tv_seasons BaseItemKind.SEASON -> R.string.tv_seasons
BaseItemKind.STUDIO -> R.string.studios BaseItemKind.STUDIO -> R.string.studios
BaseItemKind.TRAILER -> R.string.trailers_title BaseItemKind.TRAILER -> R.string.trailers
BaseItemKind.TV_CHANNEL -> R.string.channels BaseItemKind.TV_CHANNEL -> R.string.channels
BaseItemKind.TV_PROGRAM -> R.string.programs BaseItemKind.TV_PROGRAM -> TODO()
BaseItemKind.USER_ROOT_FOLDER -> R.string.folders_title BaseItemKind.USER_ROOT_FOLDER -> TODO()
BaseItemKind.USER_VIEW -> R.string.user_views BaseItemKind.USER_VIEW -> TODO()
BaseItemKind.YEAR -> R.string.year BaseItemKind.YEAR -> TODO()
BaseItemKind.AGGREGATE_FOLDER -> R.string.folders_title BaseItemKind.AGGREGATE_FOLDER -> TODO()
BaseItemKind.AUDIO_BOOK -> R.string.audio_books BaseItemKind.AUDIO_BOOK -> TODO()
BaseItemKind.BASE_PLUGIN_FOLDER -> R.string.folders_title BaseItemKind.BASE_PLUGIN_FOLDER -> TODO()
BaseItemKind.BOOK -> R.string.books BaseItemKind.BOOK -> TODO()
BaseItemKind.CHANNEL_FOLDER_ITEM -> R.string.folders_title BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO()
BaseItemKind.COLLECTION_FOLDER -> R.string.library BaseItemKind.COLLECTION_FOLDER -> TODO()
BaseItemKind.FOLDER -> R.string.folders_title BaseItemKind.FOLDER -> TODO()
BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> R.string.playlists BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO()
BaseItemKind.LIVE_TV_PROGRAM -> R.string.programs BaseItemKind.LIVE_TV_PROGRAM -> TODO()
BaseItemKind.PLAYLISTS_FOLDER -> R.string.playlists BaseItemKind.PLAYLISTS_FOLDER -> TODO()
}
/**
* Format a [Duration] into a localized "Xh Ym Zs" string using string resources.
*/
fun Resources.formatDuration(duration: Duration): String =
duration.toComponents { hours, minutes, seconds, _ ->
when {
hours > 0 && minutes > 0 && seconds > 0 -> getString(R.string.duration_hours_minutes_seconds, hours.toInt(), minutes, seconds)
hours > 0 && minutes > 0 -> getString(R.string.duration_hours_minutes, hours.toInt(), minutes)
hours > 0 && seconds > 0 -> getString(R.string.duration_hours_seconds, hours.toInt(), seconds)
hours > 0 -> getString(R.string.duration_hours, hours.toInt())
minutes > 0 && seconds > 0 -> getString(R.string.duration_minutes_seconds, minutes, seconds)
minutes > 0 -> getString(R.string.duration_minutes, minutes)
else -> getString(R.string.duration_seconds, seconds)
}
} }

View file

@ -38,25 +38,27 @@ object AppColors {
val Purple = Color(147, 51, 234) val Purple = Color(147, 51, 234)
val Green = Color(74, 222, 128) val Green = Color(74, 222, 128)
val Yellow = Color(234, 179, 8) val Yellow = Color(234, 179, 8)
val Red = Color(204, 38, 38)
} }
} }
const val DEFAULT_PAGE_SIZE = 100 const val DEFAULT_PAGE_SIZE = 100
/** /**
* The [ItemFields] to fetch for detailed paged and playback * The default [ItemFields] to fetch for most queries
*/ */
val DetailItemFields = val DefaultItemFields =
listOf( listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.SEASON_USER_DATA,
ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.TRICKPLAY, ItemFields.TRICKPLAY,
ItemFields.SORT_NAME, ItemFields.SORT_NAME,
ItemFields.CHAPTERS, ItemFields.CHAPTERS,
ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_SOURCE_COUNT, ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID,
ItemFields.CAN_DELETE, ItemFields.CAN_DELETE,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
) )
/** /**
@ -64,37 +66,27 @@ val DetailItemFields =
*/ */
val SlimItemFields = val SlimItemFields =
listOf( listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.SEASON_USER_DATA,
ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.SORT_NAME, ItemFields.SORT_NAME,
ItemFields.MEDIA_SOURCE_COUNT, ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.CAN_DELETE, ItemFields.PARENT_ID,
)
/**
* ItemFields for displaying items in rows such as in a [com.github.damontecres.wholphin.ui.cards.ItemRow] with [com.github.damontecres.wholphin.ui.cards.SeasonCard]
*/
val ItemRowFields = SlimItemFields + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO
val HomeItemFields =
listOf(
ItemFields.OVERVIEW,
ItemFields.CAN_DELETE, ItemFields.CAN_DELETE,
) )
val PhotoItemFields = val PhotoItemFields =
DetailItemFields + DefaultItemFields +
listOf( listOf(
ItemFields.WIDTH, ItemFields.WIDTH,
ItemFields.HEIGHT, ItemFields.HEIGHT,
) )
val ProgramItemFields = DetailItemFields + listOf(ItemFields.CHANNEL_INFO)
object Cards { object Cards {
const val HEIGHT_2X3_DP = 172 const val HEIGHT_2X3_DP = 172
val height2x3 = HEIGHT_2X3_DP.dp val height2x3 = HEIGHT_2X3_DP.dp
const val HEIGHT_EPISODE = 128 const val HEIGHT_EPISODE = 128
const val HEIGHT_LIVE_TV = 96
val heightEpisode = HEIGHT_EPISODE.dp val heightEpisode = HEIGHT_EPISODE.dp
val playedPercentHeight = 6.dp val playedPercentHeight = 6.dp
val serverUserCircle = height2x3 * .75f val serverUserCircle = height2x3 * .75f

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.cards package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
@ -73,7 +74,7 @@ fun BannerCard(
val imageUrlService = LocalImageUrlService.current val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current val density = LocalDensity.current
val fillHeight = val fillHeight =
remember(cardHeight, density) { remember(cardHeight) {
if (cardHeight.isSpecified) { if (cardHeight.isSpecified) {
with(density) { with(density) {
cardHeight.roundToPx() cardHeight.roundToPx()
@ -218,11 +219,13 @@ fun BannerCardWithTitle(
useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true, useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true,
) { ) {
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 0.dp else 8.dp)
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = cardHeight * aspectRationToUse val width = cardHeight * aspectRationToUse
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.width(width), modifier = modifier.width(width),
) { ) {
BannerCard( BannerCard(
@ -242,7 +245,13 @@ fun BannerCardWithTitle(
imageContentScale = imageContentScale, imageContentScale = imageContentScale,
useSeriesForPrimary = useSeriesForPrimary, useSeriesForPrimary = useSeriesForPrimary,
) )
SlidingCardText(focused) { Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text( Text(
text = title ?: "", text = title ?: "",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,

View file

@ -5,62 +5,39 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.roundSeconds import com.github.damontecres.wholphin.ui.roundSeconds
import org.jellyfin.sdk.model.api.ImageType import kotlin.time.Duration
/** /**
* Card for a [com.github.damontecres.wholphin.data.model.Chapter] * Card for a [com.github.damontecres.wholphin.data.model.Chapter]
*/ */
@Composable @Composable
fun ChapterCard( fun ChapterCard(
chapter: Chapter, name: String?,
position: Duration,
imageUrl: String?,
onClick: () -> Unit, onClick: () -> Unit,
aspectRatio: Float,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
cardHeight: Dp = 120.dp, cardHeight: Dp = 120.dp,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
) { ) {
val density = LocalDensity.current
val imageUrlService = LocalImageUrlService.current
val imageUrl =
remember(chapter, cardHeight, density) {
imageUrlService.getItemImageUrl(
itemId = chapter.itemId,
imageType = ImageType.CHAPTER,
tag = chapter.tag,
imageIndex = chapter.index,
fillHeight = with(density) { cardHeight.roundToPx() },
)
}
var width by remember { mutableStateOf(AspectRatios.WIDE * cardHeight) }
Card( Card(
modifier = modifier.height(cardHeight), modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -71,36 +48,22 @@ fun ChapterCard(
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onSuccess = {
width =
with(density) {
it.painter.intrinsicSize.width
.toDp()
}
},
) )
Box( Box(
modifier = modifier =
Modifier Modifier
.width(width)
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth()
.background(AppColors.TransparentBlack50), .background(AppColors.TransparentBlack50),
) { ) {
Column(modifier = Modifier.padding(4.dp)) { Column(modifier = Modifier.padding(4.dp)) {
chapter.name?.let { name?.let {
Text( Text(
text = it, text = it,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
) )
} }
val resources = LocalResources.current
val positionText =
remember(chapter.position) { resources.formatDuration(chapter.position.roundSeconds) }
Text( Text(
text = positionText, text = position.roundSeconds.toString(),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
) )
} }
} }

View file

@ -21,6 +21,7 @@ import com.github.damontecres.wholphin.data.model.Chapter
@Composable @Composable
fun ChapterRow( fun ChapterRow(
chapters: List<Chapter>, chapters: List<Chapter>,
aspectRatio: Float,
onClick: (Chapter) -> Unit, onClick: (Chapter) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLongClick: ((Chapter) -> Unit)? = null, onLongClick: ((Chapter) -> Unit)? = null,
@ -46,8 +47,11 @@ fun ChapterRow(
) { ) {
itemsIndexed(chapters) { index, item -> itemsIndexed(chapters) { index, item ->
ChapterCard( ChapterCard(
chapter = item, name = item.name,
position = item.position,
imageUrl = item.imageUrl,
onClick = { onClick(item) }, onClick = { onClick(item) },
aspectRatio = aspectRatio,
modifier = Modifier, modifier = Modifier,
onLongClick = onLongClick?.let { { it(item) } }, onLongClick = onLongClick?.let { { it(item) } },
) )

View file

@ -59,6 +59,8 @@ fun DiscoverItemCard(
width: Dp = Cards.height2x3 * AspectRatios.TALL, width: Dp = Cards.height2x3 * AspectRatios.TALL,
) { ) {
val focused by interactionSource.collectIsFocusedAsState() 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) } var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L val hideOverlayDelay = 500L
@ -76,7 +78,7 @@ fun DiscoverItemCard(
} }
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, Dp.Unspecified), modifier = modifier.size(width, Dp.Unspecified),
) { ) {
Card( Card(
@ -128,16 +130,7 @@ fun DiscoverItemCard(
AvailableIndicator(Modifier.align(Alignment.TopEnd)) AvailableIndicator(Modifier.align(Alignment.TopEnd))
} }
SeerrAvailability.BLOCKLISTED -> { else -> {}
// TODO handle block listed
// BlocklistedIndicator(Modifier.align(Alignment.TopEnd))
}
SeerrAvailability.UNKNOWN,
SeerrAvailability.DELETED,
null,
-> {
}
} }
if (showOverlay) { if (showOverlay) {
val color = val color =
@ -178,7 +171,13 @@ fun DiscoverItemCard(
} }
} }
} }
SlidingCardText(focused) { Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text( Text(
text = item?.title ?: "", text = item?.title ?: "",
maxLines = 1, maxLines = 1,
@ -285,31 +284,6 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) {
} }
} }
@Composable
fun BlocklistedIndicator(modifier: Modifier = Modifier) {
Box(
modifier =
modifier
.padding(4.dp)
.border(
width = .5.dp,
color = Color.White,
shape = CircleShape,
).background(
color = AppColors.Discover.Red.copy(alpha = .85f),
shape = CircleShape,
).size(16.dp),
) {
Text(
text = stringResource(R.string.fa_xmark),
fontFamily = FontAwesome,
fontSize = 10.sp,
color = Color.White,
modifier = Modifier.align(Alignment.Center),
)
}
}
@PreviewTvSpec @PreviewTvSpec
@Composable @Composable
private fun Preview() { private fun Preview() {
@ -318,7 +292,6 @@ private fun Preview() {
PendingIndicator() PendingIndicator()
AvailableIndicator() AvailableIndicator()
PartiallyAvailableIndicator() PartiallyAvailableIndicator()
BlocklistedIndicator()
} }
} }
} }

View file

@ -0,0 +1,162 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.Color
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.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.seasonEpisode
import kotlinx.coroutines.delay
@Composable
fun EpisodeCard(
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
imageHeight: Dp = Dp.Unspecified,
imageWidth: Dp = Dp.Unspecified,
showImageOverlay: Boolean = false,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val dto = item?.data
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 aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN
val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio)
val density = LocalDensity.current
val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } }
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height),
) {
Card(
modifier =
Modifier
.size(imageWidth, imageHeight)
.aspectRatio(aspectRatio),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
Box(
modifier =
Modifier
.fillMaxSize(),
) {
ItemCardImage(
item = item,
name = item?.name,
showOverlay = showImageOverlay,
favorite = dto?.userData?.isFavorite ?: false,
watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
watchedPercent = dto?.userData?.playedPercentage,
numberOfVersions = dto?.mediaSourceCount ?: 0,
useFallbackText = false,
fillWidth = imageWidthPx,
modifier =
Modifier
.fillMaxSize(),
)
Box(
modifier =
Modifier
.align(Alignment.TopEnd)
.background(
AppColors.TransparentBlack50,
shape = RoundedCornerShape(8.dp),
),
) {
Text(
text = dto?.seasonEpisode ?: "",
modifier = Modifier.padding(4.dp),
)
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = dto?.seriesName ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
Text(
text = item?.name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}

View file

@ -17,7 +17,7 @@ fun ExtrasRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ItemRow( ItemRow(
title = stringResource(R.string.extras_title), title = stringResource(R.string.extras),
items = extras, items = extras,
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = onLongClickItem, onLongClickItem = onLongClickItem,
@ -38,7 +38,7 @@ fun ExtrasRow(
unplayedItemCount = -1, unplayedItemCount = -1,
playedPercentage = item?.playedPercentage ?: 0.0, playedPercentage = item?.playedPercentage ?: 0.0,
numberOfVersions = -1, numberOfVersions = -1,
aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, aspectRatio = AspectRatios.FOUR_THREE, // TODO
) )
}, },
modifier = modifier, modifier = modifier,

View file

@ -30,6 +30,7 @@ import coil3.request.crossfade
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.components.Genre
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.setup.rememberIdColor
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import java.util.UUID import java.util.UUID

View file

@ -52,6 +52,8 @@ fun GridCard(
) { ) {
val dto = item?.data val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState() 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) } var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L val hideOverlayDelay = 500L
@ -69,7 +71,7 @@ fun GridCard(
} }
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier, modifier = modifier,
) { ) {
Card( Card(
@ -105,7 +107,13 @@ fun GridCard(
) )
} }
AnimatedVisibility(showTitle) { AnimatedVisibility(showTitle) {
SlidingCardText(focused) { Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text( Text(
text = item?.title ?: "", text = item?.title ?: "",
maxLines = 1, maxLines = 1,

View file

@ -39,7 +39,6 @@ import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.logCoilError import com.github.damontecres.wholphin.ui.logCoilError
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
@ -68,16 +67,14 @@ fun ItemCardImage(
) { ) {
val imageUrlService = LocalImageUrlService.current val imageUrlService = LocalImageUrlService.current
val imageUrl = val imageUrl =
remember(item, imageType, fillWidth, fillHeight) { remember(item) {
if (item != null && (fillWidth.gt(0) || fillHeight.gt(0))) { item?.let {
imageUrlService.getItemImageUrl( imageUrlService.getItemImageUrl(
item, item,
imageType, imageType,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
) )
} else {
null
} }
} }
ItemCardImage( ItemCardImage(
@ -265,7 +262,7 @@ fun ItemCardImageOverlay(
} }
} }
if (watchedPercent != null && watchedPercent > 0 && watchedPercent < 100) { watchedPercent?.let { percent ->
Box( Box(
modifier = modifier =
Modifier Modifier
@ -274,7 +271,7 @@ fun ItemCardImageOverlay(
MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape) ).clip(RectangleShape)
.height(Cards.playedPercentHeight) .height(Cards.playedPercentHeight)
.fillMaxWidth((watchedPercent / 100.0).toFloat()), .fillMaxWidth((percent / 100.0).toFloat()),
) )
} }
} }

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.cards package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@ -24,9 +26,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border import androidx.tv.material3.Border
@ -95,6 +99,10 @@ fun PersonCard(
focusedAfterDelay = false focusedAfterDelay = false
} }
// 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( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
modifier = modifier, modifier = modifier,
@ -162,7 +170,21 @@ fun PersonCard(
.clip(CircleShape), .clip(CircleShape),
) )
} }
SlidingCardText(focused) { Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
// 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(
text = name ?: "", text = name ?: "",
maxLines = 1, maxLines = 1,

View file

@ -33,7 +33,7 @@ fun PersonRow(
people: List<Person>, people: List<Person>,
onClick: (Person) -> Unit, onClick: (Person) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@StringRes title: Int = R.string.people_title, @StringRes title: Int = R.string.people,
onLongClick: ((Int, Person) -> Unit)? = null, onLongClick: ((Int, Person) -> Unit)? = null,
) { ) {
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
@ -84,7 +84,7 @@ fun DiscoverPersonRow(
people: List<DiscoverItem>, people: List<DiscoverItem>,
onClick: (DiscoverItem) -> Unit, onClick: (DiscoverItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@StringRes title: Int = R.string.people_title, @StringRes title: Int = R.string.people,
onLongClick: ((Int, DiscoverItem) -> Unit)? = null, onLongClick: ((Int, DiscoverItem) -> Unit)? = null,
) { ) {
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -23,8 +24,8 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -37,21 +38,51 @@ import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
/** /**
* A Card for a TV Show Season, but can generally show most items * A Card for a TV Show Season, but can generically show most items
*/ */
@Composable @Composable
fun SeasonCard( fun SeasonCard(
item: BaseItem?, item: BaseItem?,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
imageHeight: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageHeight: Dp = Dp.Unspecified,
imageWidth: Dp = Dp.Unspecified, imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false, showImageOverlay: Boolean = false,
aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL, aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL,
) { ) {
val imageUrl = rememberImageUrl(item, imageHeight, imageWidth) val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
val imageUrl =
remember(item, imageHeight, imageWidth, density) {
if (item != null) {
val fillHeight =
if (imageHeight != Dp.Unspecified) {
with(density) {
imageHeight.roundToPx()
}
} else {
null
}
val fillWidth =
if (imageWidth != Dp.Unspecified) {
with(density) {
imageWidth.roundToPx()
}
} else {
null
}
imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
null
}
}
SeasonCard( SeasonCard(
title = item?.title, title = item?.title,
subtitle = item?.subtitle, subtitle = item?.subtitle,
@ -73,47 +104,6 @@ fun SeasonCard(
) )
} }
@Composable
fun rememberImageUrl(
item: BaseItem?,
imageHeight: Dp,
imageWidth: Dp = Dp.Unspecified,
): String? {
val density = LocalDensity.current
val imageUrlService = LocalImageUrlService.current
return remember(item, imageHeight, imageWidth, density) {
if (item != null) {
val fillHeight =
if (imageHeight.isSpecified) {
with(density) {
imageHeight.roundToPx()
}
} else {
null
}
val fillWidth =
if (imageWidth.isSpecified) {
with(density) {
imageWidth.roundToPx()
}
} else {
null
}
imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
null
}
}
}
private val spaceBetweenFocused = 4.dp
private val spaceBetweenUnfocused = 12.dp
/** /**
* A Card for a TV Show Season, but can generically show most items * A Card for a TV Show Season, but can generically show most items
*/ */
@ -139,11 +129,8 @@ fun SeasonCard(
) { ) {
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
// Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
val spaceBetween = val spaceBetween = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
animateDpAsState( val spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
if (focused) spaceBetweenFocused else spaceBetweenUnfocused,
label = "spaceBetween",
)
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
@ -192,12 +179,14 @@ fun SeasonCard(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier =
Modifier Modifier
.layout { measurable, constraints -> // Optimization: move animation reads to layout/draw phase
val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx() .offset {
val bottomPaddingPx = spaceBetween.value.roundToPx() IntOffset(0, (spaceBetween.value - 4.dp).roundToPx())
}.layout { measurable, constraints ->
val paddingPx = spaceBelow.value.roundToPx()
val placeable = measurable.measure(constraints) val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) { layout(placeable.width, placeable.height + paddingPx) {
placeable.placeRelative(0, topPaddingPx) placeable.placeRelative(0, 0)
} }
}.fillMaxWidth(), }.fillMaxWidth(),
) { ) {

View file

@ -1,46 +0,0 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Slides the [content] up/down as the card is [focused] or not
*
* This is used for the card's title, moving it down when focused as the card itself scales up
*/
@Composable
fun SlidingCardText(
focused: Boolean,
modifier: Modifier = Modifier,
spaceBetweenFocused: Dp = 4.dp,
spaceBetweenUnfocused: Dp = 12.dp,
content: @Composable ColumnScope.() -> Unit,
) {
val spaceBetween =
animateDpAsState(
if (focused) spaceBetweenFocused else spaceBetweenUnfocused,
label = "spaceBetween",
)
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
modifier
.layout { measurable, constraints ->
val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx()
val bottomPaddingPx = spaceBetween.value.roundToPx()
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) {
placeable.placeRelative(0, topPaddingPx)
}
}.fillMaxWidth(),
content = content,
)
}

View file

@ -32,7 +32,9 @@ import coil3.request.ImageRequest
import coil3.request.crossfade import coil3.request.crossfade
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.PreviewTvSpec 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.components.Studio
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.setup.rememberIdColor
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import java.util.UUID import java.util.UUID

View file

@ -53,6 +53,8 @@ fun ViewMoreCard(
showTitle: Boolean = true, showTitle: Boolean = true,
) { ) {
val focused by interactionSource.collectIsFocusedAsState() 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) } var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L val hideOverlayDelay = 500L
@ -77,7 +79,7 @@ fun ViewMoreCard(
size.height.takeIf { it.isSpecified } ?: (size.height * (1f / aspectRatio.ratio)) size.height.takeIf { it.isSpecified } ?: (size.height * (1f / aspectRatio.ratio))
} }
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier, modifier = modifier,
) { ) {
Card( Card(
@ -93,20 +95,24 @@ fun ViewMoreCard(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
), ),
) { ) {
Box( Box {
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(),
) {
Icon( Icon(
imageVector = Icons.Default.ArrowForward, imageVector = Icons.Default.ArrowForward,
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
contentDescription = "View more", contentDescription = "View more",
modifier = Modifier.fillMaxSize(.66f), modifier = Modifier.fillMaxSize(),
) )
} }
} }
if (showTitle) { if (showTitle) {
SlidingCardText(focused) { Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
Modifier
.width(width)
.padding(bottom = spaceBelow),
) {
Text( Text(
text = stringResource(R.string.view_more), text = stringResource(R.string.view_more),
maxLines = 1, maxLines = 1,

View file

@ -45,8 +45,6 @@ fun WatchedIconBackground(): Color =
AppThemeColors.GREEN, AppThemeColors.GREEN,
AppThemeColors.ORANGE, AppThemeColors.ORANGE,
AppThemeColors.BOLD_BLUE, AppThemeColors.BOLD_BLUE,
AppThemeColors.RED,
AppThemeColors.BROWN,
-> MaterialTheme.colorScheme.border.copy(alpha = 1f) -> MaterialTheme.colorScheme.border.copy(alpha = 1f)
AppThemeColors.OLED_BLACK -> MaterialTheme.colorScheme.secondaryContainer AppThemeColors.OLED_BLACK -> MaterialTheme.colorScheme.secondaryContainer
@ -62,8 +60,6 @@ fun WatchedIconColor(): Color =
AppThemeColors.ORANGE, AppThemeColors.ORANGE,
AppThemeColors.BOLD_BLUE, AppThemeColors.BOLD_BLUE,
AppThemeColors.OLED_BLACK, AppThemeColors.OLED_BLACK,
AppThemeColors.RED,
AppThemeColors.BROWN,
-> Color.White // MaterialTheme.colorScheme.onSurface -> Color.White // MaterialTheme.colorScheme.onSurface
} }

View file

@ -7,10 +7,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -18,9 +15,6 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun CircularProgress(modifier: Modifier = Modifier) { fun CircularProgress(modifier: Modifier = Modifier) {
@ -64,41 +58,3 @@ fun LoadingPage(
) )
} }
} }
/**
* Fill the space and take focus, showing a loading indicator after the specified delay
*/
@Composable
fun DelayedLoadingPage(
modifier: Modifier = Modifier,
focusEnabled: Boolean = true,
delay: Duration = 300.milliseconds,
) {
val focusRequester = remember { FocusRequester() }
if (focusEnabled) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
}
var show by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(delay)
show = true
}
Box(
contentAlignment = Alignment.Center,
modifier =
modifier
.fillMaxSize()
.focusRequester(focusRequester)
.focusable(focusEnabled),
) {
if (show) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier =
Modifier
.align(Alignment.Center)
.size(48.dp),
)
}
}
}

View file

@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -22,7 +21,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -42,7 +40,6 @@ import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.GridItemDetails import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
@ -115,7 +112,7 @@ fun CollectionFolderGrid(
columns = viewOptions.columns, columns = viewOptions.columns,
spacing = viewOptions.spacing.dp, spacing = viewOptions.spacing.dp,
bringIntoViewSpec = bringIntoViewSpec =
remember(viewOptions, density) { remember(viewOptions) {
val spacingPx = with(density) { viewOptions.spacing.dp.toPx() } val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
if (viewOptions.showDetails) { if (viewOptions.showDetails) {
ScrollToTopBringIntoViewSpec(spacingPx) ScrollToTopBringIntoViewSpec(spacingPx)
@ -207,7 +204,7 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
} }
CollectionType.TVSHOWS -> { CollectionType.TVSHOWS -> {
listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE) listOf(BaseItemKind.SERIES)
} }
CollectionType.HOMEVIDEOS -> { CollectionType.HOMEVIDEOS -> {
@ -222,10 +219,6 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
) )
} }
CollectionType.MUSICVIDEOS -> {
listOf(BaseItemKind.MUSIC_VIDEO)
}
CollectionType.BOXSETS -> { CollectionType.BOXSETS -> {
listOf(BaseItemKind.BOX_SET) listOf(BaseItemKind.BOX_SET)
} }
@ -234,33 +227,7 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
listOf(BaseItemKind.PLAYLIST) listOf(BaseItemKind.PLAYLIST)
} }
CollectionType.PHOTOS -> { else -> {
listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
}
CollectionType.LIVETV -> {
listOf(
BaseItemKind.TV_CHANNEL,
BaseItemKind.LIVE_TV_CHANNEL,
BaseItemKind.PROGRAM,
BaseItemKind.TV_PROGRAM,
BaseItemKind.LIVE_TV_PROGRAM,
)
}
CollectionType.TRAILERS -> {
listOf(BaseItemKind.TRAILER)
}
CollectionType.BOOKS -> {
listOf(BaseItemKind.BOOK)
}
CollectionType.FOLDERS -> {
listOf(BaseItemKind.FOLDER)
}
CollectionType.UNKNOWN -> {
listOf() listOf()
} }
} }
@ -289,7 +256,6 @@ data class GridClickActions(
@Composable @Composable
fun CollectionFolderHeader( fun CollectionFolderHeader(
listIsNotEmpty: Boolean,
showHeader: Boolean, showHeader: Boolean,
showTitle: Boolean, showTitle: Boolean,
playEnabled: Boolean, playEnabled: Boolean,
@ -300,14 +266,10 @@ fun CollectionFolderHeader(
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
onClickShowViewOptions: () -> Unit, onClickShowViewOptions: () -> Unit,
onClickPlayAll: (Boolean) -> Unit, onClickPlayAll: (Boolean) -> Unit,
onClickRandom: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
currentFilter: GetItemsFilter = GetItemsFilter(), currentFilter: GetItemsFilter = GetItemsFilter(),
filterOptions: List<ItemFilterBy<*>> = listOf(), filterOptions: List<ItemFilterBy<*>> = listOf(),
onFilterChange: (GetItemsFilter) -> Unit = {}, onFilterChange: (GetItemsFilter) -> Unit = {},
onShowFilterDropdown: ((Boolean) -> Unit)? = null,
filterButtonFocusRequester: FocusRequester = remember { FocusRequester() },
randomButtonFocusRequester: FocusRequester = remember { FocusRequester() },
) { ) {
AnimatedVisibility( AnimatedVisibility(
showHeader, showHeader,
@ -315,7 +277,6 @@ fun CollectionFolderHeader(
exit = shrinkVertically(), exit = shrinkVertically(),
modifier = modifier, modifier = modifier,
) { ) {
val focusRequester = remember { FocusRequester() }
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -325,15 +286,15 @@ fun CollectionFolderHeader(
} }
val endPadding = val endPadding =
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp, end = endPadding) .padding(start = 16.dp, end = endPadding)
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -344,7 +305,7 @@ fun CollectionFolderHeader(
sortOptions = sortOptions, sortOptions = sortOptions,
current = sortAndDirection, current = sortAndDirection,
onSortChange = onSortChange, onSortChange = onSortChange,
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier,
) )
} }
if (filterOptions.isNotEmpty()) { if (filterOptions.isNotEmpty()) {
@ -353,56 +314,33 @@ fun CollectionFolderHeader(
current = currentFilter, current = currentFilter,
onFilterChange = onFilterChange, onFilterChange = onFilterChange,
getPossibleValues = getPossibleFilterValues, getPossibleValues = getPossibleFilterValues,
modifier = modifier = Modifier,
Modifier
.focusRequester(filterButtonFocusRequester)
.ifElse(
sortOptions.isEmpty(),
Modifier.focusRequester(focusRequester),
),
onShow = onShowFilterDropdown,
) )
} }
ExpandableFaButton( ExpandableFaButton(
title = R.string.view_options, title = R.string.view_options,
iconStringRes = R.string.fa_sliders, iconStringRes = R.string.fa_sliders,
onClick = onClickShowViewOptions, onClick = onClickShowViewOptions,
modifier = modifier = Modifier,
Modifier.ifElse(
sortOptions.isEmpty() && filterOptions.isEmpty(),
Modifier.focusRequester(focusRequester),
),
) )
} }
}
Spacer(Modifier.weight(1f)) if (playEnabled) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier, modifier = Modifier,
) { ) {
ExpandableFaButton(
title = R.string.sort_by_random,
iconStringRes = R.string.fa_dice,
onClick = onClickRandom,
modifier = Modifier.focusRequester(randomButtonFocusRequester),
enabled = listIsNotEmpty,
)
if (playEnabled) {
ExpandablePlayButton( ExpandablePlayButton(
title = R.string.play, title = R.string.play,
resume = Duration.ZERO, resume = Duration.ZERO,
icon = Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
onClick = { onClickPlayAll.invoke(false) }, onClick = { onClickPlayAll.invoke(false) },
enabled = listIsNotEmpty,
) )
ExpandableFaButton( ExpandableFaButton(
title = R.string.shuffle, title = R.string.shuffle,
iconStringRes = R.string.fa_shuffle, iconStringRes = R.string.fa_shuffle,
onClick = { onClickPlayAll.invoke(true) }, onClick = { onClickPlayAll.invoke(true) },
enabled = listIsNotEmpty,
) )
} }
} }

View file

@ -28,7 +28,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -39,7 +38,6 @@ import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.ListItemDefaults import androidx.tv.material3.ListItemDefaults
@ -60,7 +58,6 @@ import com.github.damontecres.wholphin.ui.cards.WatchedIcon
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.AlphabetButtons import com.github.damontecres.wholphin.ui.detail.AlphabetButtons
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
@ -100,17 +97,15 @@ fun CollectionFolderList(
val positionFocusRequester = remember { FocusRequester() } val positionFocusRequester = remember { FocusRequester() }
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by remember { mutableStateOf(true) }
fun jumpTo(newPosition: Int) { fun jumpTo(newPosition: Int) {
scope.launch { scope.launch {
if (newPosition >= 0) {
position = newPosition position = newPosition
listState.animateScrollToItem(newPosition) listState.animateScrollToItem(newPosition)
positionFocusRequester.tryRequestFocus() positionFocusRequester.tryRequestFocus()
} }
} }
}
BackHandler(contentHashFocus && position > 0) { BackHandler(contentHashFocus && position > 0) {
jumpTo(0) jumpTo(0)
@ -362,7 +357,7 @@ fun ListItemGeneric(
?.runTimeTicks ?.runTimeTicks
?.ticks ?.ticks
?.roundMinutes ?.roundMinutes
?.let { LocalResources.current.formatDuration(it) } ?: "", ?.toString() ?: "",
) )
}, },
scale = scale, scale = scale,
@ -424,7 +419,7 @@ fun ListItemMovie(
?.runTimeTicks ?.runTimeTicks
?.ticks ?.ticks
?.roundMinutes ?.roundMinutes
?.let { LocalResources.current.formatDuration(it) } ?: "", ?.toString() ?: "",
) )
if (item?.played == true) { if (item?.played == true) {
WatchedIcon(padding = 0.dp) WatchedIcon(padding = 0.dp)
@ -551,7 +546,7 @@ fun ListItemAlbum(
.runTimeTicks .runTimeTicks
?.ticks ?.ticks
?.roundMinutes ?.roundMinutes
?.let { LocalResources.current.formatDuration(it) } ?: "", ?.toString() ?: "",
) )
} }
} else { } else {

Some files were not shown because too many files have changed in this diff Show more