diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml
index 1aab31da..5f52226f 100644
--- a/.github/ISSUE_TEMPLATE/00-bug.yml
+++ b/.github/ISSUE_TEMPLATE/00-bug.yml
@@ -1,6 +1,5 @@
name: "Bug Report"
description: Report a bug
-title: "
"
labels: [
"bug"
]
@@ -23,6 +22,16 @@ body:
3. See error
validations:
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
id: app-version
attributes:
diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml
index cbb287b5..389d4e8a 100644
--- a/.github/ISSUE_TEMPLATE/01-playback.yml
+++ b/.github/ISSUE_TEMPLATE/01-playback.yml
@@ -1,6 +1,5 @@
name: "Playback Problem"
description: Report an issue with playback
-title: ""
labels: [
"bug", "playback"
]
@@ -12,12 +11,14 @@ body:
description: Please enter a description of the playback issue
validations:
required: true
- - type: textarea
- id: mediainfo
+ - type: dropdown
+ id: experimental
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
+ label: Experimental settings
+ description: Do you have experiment settings turn on?
+ options:
+ - "Yes"
+ - "No"
validations:
required: true
- type: dropdown
@@ -32,6 +33,14 @@ body:
- External App (please specify)
validations:
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
id: app-version
attributes:
diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index d7b2f290..24711c4a 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -6,11 +6,11 @@ runs:
steps:
# Setup the SDKs
- name: Set up Python
- uses: actions/setup-python@v6
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.14'
- name: Setup JDK
- uses: actions/setup-java@v5
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6
with:
distribution: zulu
java-version: '21'
@@ -21,9 +21,9 @@ runs:
echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
- name: Setup Android SDK
- uses: android-actions/setup-android@v4
+ uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4
with:
- packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
+ packages: "platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
- name: Add NDK to path
shell: bash
run: |
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 6cb20eeb..3bd2e54d 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -16,7 +16,7 @@ concurrency:
env:
BRANCH_NAME: ${{ github.ref_name }}
- GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
+ GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'"
jobs:
build:
@@ -25,7 +25,7 @@ jobs:
contents: write
steps:
- name: Checkout the code
- uses: actions/checkout@v6
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0 # Need the tags to build
- name: Setup
@@ -54,7 +54,8 @@ jobs:
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
run: |
- ./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon
+ ./gradlew clean assembleDefaultDebug
+ ./gradlew assembleDefaultRelease
- name: Verify signatures
run: |
@@ -76,7 +77,7 @@ jobs:
echo "SHA256 checksums:"
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
- name: Upload mapping files
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: mapping
path: |
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 8607abd8..9e518406 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -9,29 +9,29 @@ defaults:
env:
BUILD_DIRS_ARTIFACT: build-dirs
- GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
+ GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'"
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- name: Checkout the code
- uses: actions/checkout@v6
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0 # Need the tags to build
- name: Setup Python
- uses: actions/setup-python@v6
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.14'
- name: Run pre-commit
- uses: pre-commit/action@v3.0.1
+ uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
build:
runs-on: ubuntu-latest
needs: pre-commit
steps:
- name: Checkout the code
- uses: actions/checkout@v6
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0 # Need the tags to build
- name: Setup
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b0520630..4822ce7e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,7 +11,7 @@ defaults:
env:
APK_SHORT_NAME: Wholphin.apk
- GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
+ GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'"
TAG_NAME: ${{ github.ref_name }}
jobs:
@@ -21,7 +21,7 @@ jobs:
contents: write
steps:
- name: Checkout the code
- uses: actions/checkout@v6
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0 # Need the tags to build
- name: Setup
@@ -36,7 +36,8 @@ jobs:
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
run: |
- ./gradlew clean bundleAppstoreRelease bundleFiretvRelease
+ ./gradlew clean bundleAppstoreRelease
+ ./gradlew bundleFiretvRelease
./gradlew assembleDefaultRelease
- name: Verify signatures
run: |
@@ -58,14 +59,14 @@ jobs:
echo "SHA256 checksums:"
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
- name: Upload AAB
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: AAB
path: |
app/build/outputs/bundle/**/*.aab
compression-level: 0
- name: Upload mapping files
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: mapping
path: |
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index a2338108..0d818e21 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -42,6 +42,8 @@ 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.
+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 is split into several packages:
@@ -95,3 +97,9 @@ 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.
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.
diff --git a/Intents.md b/Intents.md
new file mode 100644
index 00000000..7f01a501
--- /dev/null
+++ b/Intents.md
@@ -0,0 +1,168 @@
+# 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"
+```
diff --git a/README.md b/README.md
index 07f491a1..ed82692f 100644
--- a/README.md
+++ b/README.md
@@ -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!
-Seerr integration is tested with `v3.2.0`. Older versions may not work.
+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.
## Contributions
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e7230f0c..e005cb6f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -20,12 +20,21 @@ plugins {
alias(libs.plugins.openapi.generator)
}
-val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false
-val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
-val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
-val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists()
-val mpvModuleExists = project.file("libs/wholphin-mpv-release.aar").exists()
-val extensionsRepoActive = project.hasProperty("WholphinExtensionsUsername")
+val isCI = providers.environmentVariable("CI").orElse("false").map { it.toBoolean() }
+val shouldSign =
+ isCI.zip(
+ providers.environmentVariable("KEY_ALIAS").orElse("").map { it.isNotBlank() },
+ ) { isCI, hasKey ->
+ isCI && hasKey
+ }
+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
val isBuildingBundle =
@@ -50,17 +59,22 @@ kotlin {
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
jvmTarget = JvmTarget.JVM_11
javaParameters = true
+
+ // Workaround for https://github.com/google/ksp/issues/2964
+ moduleName = "${project.group}_${project.name}"
}
}
+private fun Provider.getInt() = get().toInt()
+
configure {
namespace = "com.github.damontecres.wholphin"
- compileSdk = 36
+ compileSdk = libs.versions.compileSdk.getInt()
defaultConfig {
applicationId = "com.github.damontecres.wholphin"
- minSdk = 23
- targetSdk = 36
+ minSdk = libs.versions.minSdk.getInt()
+ targetSdk = libs.versions.targetSdk.getInt()
versionCode = gitTags.trim().lines().size
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
@@ -69,7 +83,7 @@ configure {
}
signingConfigs {
- if (shouldSign) {
+ if (shouldSign.get()) {
create("ci") {
file("ci.keystore").writeBytes(
Base64.getDecoder().decode(System.getenv("SIGNING_KEY")),
@@ -95,7 +109,7 @@ configure {
"proguard-rules.pro",
)
isDebuggable = false
- if (shouldSign) {
+ if (shouldSign.get()) {
signingConfig = signingConfigs.getByName("ci")
} else {
val localPropertiesFile = project.rootProject.file("local.properties")
@@ -166,7 +180,7 @@ configure {
isEnable = !isBuildingBundle.get()
reset()
- include("armeabi-v7a", "arm64-v8a")
+ include("armeabi-v7a", "arm64-v8a", "x86_64")
isUniversalApk = true
}
}
@@ -323,7 +337,6 @@ dependencies {
implementation(libs.androidx.room.common.jvm)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.compose.material3)
- implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.room.testing)
@@ -351,40 +364,39 @@ dependencies {
implementation(libs.kache)
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)
- if (ffmpegModuleExists) {
+ if (ffmpegModuleExists.get()) {
logger.info("Using local ffmpeg decoder")
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
- } else if (extensionsRepoActive) {
+ } else if (extensionsRepoActive.get()) {
logger.info("Using prebuilt ffmpeg decoder")
implementation(libs.wholphin.extensions.ffmpeg)
} else {
logger.warn("Media3 ffmpeg decoder was NOT found")
}
- if (av1ModuleExists) {
+ if (av1ModuleExists.get()) {
logger.info("Using local av1 decoder")
implementation(files("libs/lib-decoder-av1-release.aar"))
- } else if (extensionsRepoActive) {
+ } else if (extensionsRepoActive.get()) {
logger.info("Using prebuilt av1 decoder")
implementation(libs.wholphin.extensions.av1)
} else {
logger.warn("Media3 av1 decoder was NOT found")
}
- if (mpvModuleExists) {
+ if (mpvModuleExists.get()) {
logger.info("Using local libMPV build")
implementation(files("libs/wholphin-mpv-release.aar"))
- } else if (extensionsRepoActive) {
+ } else if (extensionsRepoActive.get()) {
logger.info("Using prebuilt libMPV")
implementation(libs.wholphin.extensions.mpv)
} else {
- logger.warn("libMPV was NOT found")
+ logger.warn("libMPV was NOT found, using stub library")
+ 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.agent)
testImplementation(libs.kotlinx.coroutines.test)
@@ -392,7 +404,8 @@ dependencies {
testImplementation(libs.robolectric)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.androidx.compose.ui.test.junit4)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.mockk.android)
androidTestImplementation(libs.hilt.android.testing)
- androidTestImplementation(libs.androidx.ui.test.manifest)
+ androidTestImplementation(libs.androidx.compose.ui.test.manifest)
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 8210c734..cddfb043 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,6 +1,6 @@
-keepattributes SourceFile,LineNumberTable
--keep class com.github.damontecres.wholphin.util.mpv.* { *; }
+-keep class com.github.damontecres.wholphin.mpv.MPVLib { *; }
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
@@ -11,3 +11,14 @@
# TODO investigate using smaller scope
-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;
+}
diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json
new file mode 100644
index 00000000..a5709ec2
--- /dev/null
+++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json
@@ -0,0 +1,720 @@
+{
+ "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')"
+ ]
+ }
+}
diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json
new file mode 100644
index 00000000..f82e6bdf
--- /dev/null
+++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json
@@ -0,0 +1,741 @@
+{
+ "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')"
+ ]
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 64e7493c..582e000a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
android:installLocation="auto">
+
@@ -51,13 +52,21 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
- android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
+ android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|density">
+
+
+
+
+
+
+
+
+ @Inject
+ lateinit var userPreferencesService: UserPreferencesService
+
@AuthOkHttpClient
@Inject
lateinit var okHttpClient: OkHttpClient
@@ -137,11 +148,16 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var screensaverService: ScreensaverService
+ @Inject
+ lateinit var intentService: IntentService
+
private var signInAuto = true
+ private var playerBackend: PlayerBackend? = null
private val json =
Json {
classDiscriminator = "_type"
+ serializersModule = requestSerializersModule
}
@OptIn(ExperimentalTvMaterial3Api::class)
@@ -152,37 +168,51 @@ class MainActivity : AppCompatActivity() {
lifecycle.addObserver(playbackLifecycleObserver)
val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK)
- if (backStackStr != null) {
+ val restoredBackStack =
+ if (backStackStr != null) {
+ try {
+ json.decodeFromString>(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")
- var backStack = json.decodeFromString>(backStackStr)
-
+ var backStack = restoredBackStack
if (!playExternalViewModel.launched.value) {
val lastDest = backStack.lastOrNull()
if (lastDest.isPlayback) {
- Timber.v("Restoring back stack with playback")
+ Timber.v("Restoring back stack without playback")
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
}
}
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
} else {
- val startDestination = intent?.let(::extractDestination) ?: Destination.Home()
- navigationManager.backStack = NavBackStack(startDestination)
+ navigationManager.backStack = NavBackStack(Destination.Home())
}
- viewModel.serverRepository.currentUserFlow.collectLatestIn(lifecycleScope) { user ->
- if (user?.hasPin == true) {
- window?.setFlags(
- WindowManager.LayoutParams.FLAG_SECURE,
- WindowManager.LayoutParams.FLAG_SECURE,
- )
- } else {
- window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
- }
- }
+ viewModel.serverRepository.currentUserFlow
+ .onEach { user ->
+ withContext(WholphinDispatchers.Main) {
+ if (user?.hasPin == true) {
+ window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ } else {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+ }
+ }.catch { ex ->
+ Timber.e(ex, "Error with settings flag secure")
+ }.launchIn(lifecycleScope)
+
screensaverService.keepScreenOn
.onEach { keepScreenOn ->
Timber.v("keepScreenOn: %s", keepScreenOn)
- withContext(Dispatchers.Main) {
+ withContext(WholphinDispatchers.Main) {
if (keepScreenOn) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
@@ -193,55 +223,63 @@ class MainActivity : AppCompatActivity() {
Timber.e(ex, "Error with keepScreenOn")
}.launchIn(lifecycleScope)
- viewModel.appStart()
+ userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs ->
+ signInAuto = prefs.signInAutomatically
+ playerBackend = prefs.playbackPreferences.playerBackend
+ }
+
+ viewModel.appStart(intent)
setContent {
- val appPreferences by userPreferencesDataStore.data.collectAsState(null)
- if (appPreferences == null) {
- // Show loading page if it is taking a while to get app preferences
- var showLoading by remember { mutableStateOf(false) }
- LaunchedEffect(Unit) {
- delay(500)
- Timber.i("Showing loading page")
- showLoading = true
- }
- if (showLoading) {
- Box(
- modifier =
- Modifier
- .fillMaxSize()
- .background(Color.Black),
- ) {
- LoadingPage()
- }
- }
- }
- appPreferences?.let { appPreferences ->
- LaunchedEffect(appPreferences.signInAutomatically) {
- signInAuto = appPreferences.signInAutomatically
- }
- CoilConfig(
- prefs = appPreferences,
- okHttpClient = okHttpClient,
- debugLogging = false,
- enableCache = true,
- )
- LaunchedEffect(appPreferences.debugLogging) {
- DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
- }
- CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
- WholphinTheme(
- true,
- appThemeColors = appPreferences.interfacePreferences.appThemeColors,
- ) {
- ProvideLocalClock {
- MainContent(
- backStack = setupNavigationManager.backStack,
- navigationManager = navigationManager,
- appPreferences = appPreferences,
- backdropService = backdropService,
- screensaverService = screensaverService,
- modifier = Modifier.fillMaxSize(),
+ MaterialTheme(colorScheme = PurpleThemeColors.darkScheme) {
+ Surface(Modifier.fillMaxSize()) {
+ val userPreferences by userPreferencesService.flow.collectAsState(null)
+ if (userPreferences == null) {
+ // Show loading page if it is taking a while to get app preferences
+ var showLoading by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ delay(500.milliseconds)
+ Timber.i("Showing loading page")
+ showLoading = true
+ }
+ if (showLoading) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black),
+ ) {
+ LoadingPage()
+ }
+ }
+ } else {
+ userPreferences?.let { userPreferences ->
+ val appPreferences = userPreferences.appPreferences
+ CoilConfig(
+ prefs = appPreferences,
+ okHttpClient = okHttpClient,
+ debugLogging = false,
+ enableCache = true,
)
+ LaunchedEffect(appPreferences.debugLogging) {
+ DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
+ }
+ CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
+ WholphinTheme(
+ true,
+ appThemeColors = appPreferences.interfacePreferences.appThemeColors,
+ ) {
+ ProvideLocalClock {
+ MainContent(
+ backStack = setupNavigationManager.backStack,
+ navigationManager = navigationManager,
+ userPreferences = userPreferences,
+ backdropService = backdropService,
+ screensaverService = screensaverService,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+ }
}
}
}
@@ -250,7 +288,7 @@ class MainActivity : AppCompatActivity() {
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
- if (screensaverService.state.value.show) {
+ if (screensaverService.state.value.run { show || showDim }) {
screensaverService.stop(false)
screensaverService.pulse()
return true
@@ -263,6 +301,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
Timber.d("onResume")
+ viewModel.appResume()
lifecycleScope.launchDefault {
screensaverService.pulse()
}
@@ -271,7 +310,7 @@ class MainActivity : AppCompatActivity() {
override fun onRestart() {
super.onRestart()
Timber.d("onRestart")
- viewModel.appStart()
+ viewModel.appStart(null)
if (!playExternalViewModel.launched.value) {
// If restarting during playback that is not external, go back a page
val lastDest = navigationManager.backStack.lastOrNull()
@@ -318,10 +357,14 @@ class MainActivity : AppCompatActivity() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
Timber.d("onSaveInstanceState")
- val str = json.encodeToString(navigationManager.backStack.toList())
- outState.putString(KEY_BACK_STACK, str)
- val playerBackend =
- runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend
+ try {
+ val str = json.encodeToString(navigationManager.backStack.toList())
+ outState.putString(KEY_BACK_STACK, str)
+ } catch (ex: Exception) {
+ // Best-effort: some destinations carry types we can't serialize; skip persisting
+ // 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)
}
@@ -337,67 +380,34 @@ class MainActivity : AppCompatActivity() {
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
- Timber.d("onConfigurationChanged")
+ Timber.d("onConfigurationChanged: newConfig=%s", newConfig)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
Timber.v("onNewIntent")
setIntent(intent)
- extractDestination(intent)?.let {
- navigationManager.replace(it)
- }
+ viewModel.appStart(intent)
}
- 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) {
- lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) {
- val attrs = window.attributes
- if (attrs.preferredDisplayModeId != modeId) {
- Timber.d("Switch preferredDisplayModeId to %s", modeId)
- window.attributes = attrs.apply { preferredDisplayModeId = modeId }
+ lifecycleScope.launch(WholphinDispatchers.Main + ExceptionHandler(autoToast = true)) {
+ try {
+ val attrs = window.attributes
+ if (attrs.preferredDisplayModeId != modeId) {
+ Timber.d("Switch preferredDisplayModeId to %s", 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 {
- 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_EXTERNAL_PLAYER = "extPlayer"
@@ -413,68 +423,137 @@ class MainActivityViewModel
@param:ApplicationContext private val context: Context,
private val preferences: DataStore,
val serverRepository: ServerRepository,
- private val navigationManager: SetupNavigationManager,
+ private val setupNavigationManager: SetupNavigationManager,
+ private val navigationManager: NavigationManager,
private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler,
+ private val intentService: IntentService,
) : ViewModel() {
- fun appStart() {
+ private val mutex = Mutex()
+
+ fun appStart(intent: Intent?) {
viewModelScope.launchDefault {
- try {
- val needUpgrade = appUpgradeHandler.needUpgrade()
- if (needUpgrade) {
- showToast(
- context,
- context.getString(
- R.string.updated_toast,
- appUpgradeHandler.currentVersion.toString(),
- ),
- )
- appUpgradeHandler.run()
+ 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")
}
- appUpgradeHandler.copySubfont(false)
- val prefs =
- preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
- val profileProtected =
- serverRepository.current.value?.user?.let {
- it.hasPin || it.requireLogin
- } == true
- if (prefs.signInAutomatically && !profileProtected) {
- val current =
- serverRepository.restoreSession(
- prefs.currentServerId?.toUUIDOrNull(),
- prefs.currentUserId?.toUUIDOrNull(),
+
+ try {
+ val needUpgrade = appUpgradeHandler.needUpgrade()
+ if (needUpgrade) {
+ showToast(
+ context,
+ context.getString(
+ R.string.updated_toast,
+ appUpgradeHandler.currentVersion.toString(),
+ ),
)
- if (current != null) {
- if (current.user.hasPin || current.user.requireLogin) {
- navigationManager.navigateTo(SetupDestination.UserList(current.server))
+ appUpgradeHandler.run()
+ }
+ appUpgradeHandler.copySubfont(false)
+ val prefs =
+ preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
+ val profileProtected =
+ serverRepository.current.value
+ ?.user
+ ?.isProtected == true
+ if (prefs.signInAutomatically && !profileProtected) {
+ val current =
+ serverRepository.restoreSession(
+ prefs.currentServerId?.toUUIDOrNull(),
+ prefs.currentUserId?.toUUIDOrNull(),
+ )
+ if (current != null) {
+ if (current.user.isProtected) {
+ setupNavigationManager.navigateTo(
+ SetupDestination.UserList(
+ current.server,
+ ),
+ )
+ } else {
+ // Restored
+ setupNavigationManager.navigateTo(
+ SetupDestination.AppContent(
+ current,
+ ),
+ )
+ }
} else {
- // Restored
- navigationManager.navigateTo(SetupDestination.AppContent(current))
+ // Did not restore
+ setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
- // Did not restore
- navigationManager.navigateTo(SetupDestination.ServerList)
- }
- } else {
- navigationManager.navigateTo(SetupDestination.Loading)
- backdropService.clearBackdrop()
- val currentServerId = prefs.currentServerId?.toUUIDOrNull()
- if (currentServerId != null) {
- val currentServer =
- serverRepository.serverDao.getServer(currentServerId)?.server
- if (currentServer != null) {
- navigationManager.navigateTo(SetupDestination.UserList(currentServer))
+ setupNavigationManager.navigateTo(SetupDestination.Loading)
+ backdropService.clearBackdrop()
+ val currentServerId = prefs.currentServerId?.toUUIDOrNull()
+ if (currentServerId != null) {
+ val currentServer =
+ serverRepository.serverDao.getServer(currentServerId)?.server
+ if (currentServer != null) {
+ setupNavigationManager.navigateTo(
+ SetupDestination.UserList(
+ currentServer,
+ ),
+ )
+ } else {
+ setupNavigationManager.navigateTo(SetupDestination.ServerList)
+ }
} else {
- navigationManager.navigateTo(SetupDestination.ServerList)
+ setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
- } else {
- navigationManager.navigateTo(SetupDestination.ServerList)
}
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error during appStart")
+ setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
- } catch (ex: Exception) {
- Timber.e(ex, "Error during appStart")
- navigationManager.navigateTo(SetupDestination.ServerList)
}
}
viewModelScope.launchDefault {
@@ -482,6 +561,19 @@ class MainActivityViewModel
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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
index 714d80dc..26dda908 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
@@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberUpdatedState
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.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
@@ -27,7 +28,6 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
-import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
@@ -44,12 +44,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
fun MainContent(
backStack: MutableList,
navigationManager: NavigationManager,
- appPreferences: AppPreferences,
+ userPreferences: UserPreferences,
backdropService: BackdropService,
screensaverService: ScreensaverService,
modifier: Modifier = Modifier,
) {
- val preferences by rememberUpdatedState(UserPreferences(appPreferences))
+ val preferences by rememberUpdatedState(userPreferences)
Surface(
modifier =
modifier
@@ -59,7 +59,7 @@ fun MainContent(
// val backStack = rememberNavBackStack(SetupDestination.Loading)
// setupNavigationManager.backStack = backStack
val interfaceCustomization =
- remember(appPreferences) { InterfaceCustomization(appPreferences) }
+ remember(userPreferences.appPreferences) { InterfaceCustomization(userPreferences.appPreferences) }
CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) {
NavDisplay(
backStack = backStack,
@@ -105,7 +105,7 @@ fun MainContent(
mutableStateOf(true)
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
- if (!appPreferences.signInAutomatically) {
+ if (!userPreferences.appPreferences.signInAutomatically) {
showContent = false
}
}
@@ -137,12 +137,28 @@ fun MainContent(
val screenSaverState by screensaverService.state.collectAsState()
if (screenSaverState.enabled || screenSaverState.enabledTemp) {
AnimatedVisibility(
- screenSaverState.show,
- Modifier.fillMaxSize(),
+ visible = screenSaverState.show,
+ enter = ScreensaverService.enterAnimation,
+ exit = ScreensaverService.exitAnimation,
+ modifier = Modifier.fillMaxSize(),
) {
- AppScreensaver(appPreferences, Modifier.fillMaxSize())
+ AppScreensaver(userPreferences.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)),
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
index ceb7264e..bd25a2d4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
@@ -15,6 +15,8 @@ import org.acra.ReportField
import org.acra.config.dialog
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
+import org.jellyfin.sdk.Jellyfin
+import org.jellyfin.sdk.model.ServerVersion
import timber.log.Timber
import javax.inject.Inject
@@ -120,5 +122,7 @@ class WholphinApplication :
companion object {
lateinit var instance: WholphinApplication
private set
+
+ val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
index a281b101..57d4a447 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
@@ -1,6 +1,8 @@
package com.github.damontecres.wholphin
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.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -9,6 +11,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.datastore.core.DataStore
import androidx.lifecycle.Lifecycle
@@ -104,13 +107,23 @@ class WholphinDreamService :
ProvideLocalClock {
val screensaverPrefs = prefs.interfacePreferences.screensaverPreference
val currentItem by itemFlow.collectAsState(null)
- AppScreensaverContent(
- currentItem = currentItem,
- showClock = screensaverPrefs.showClock,
- duration = screensaverPrefs.duration.milliseconds,
- animate = screensaverPrefs.animate,
- modifier = Modifier.fillMaxSize(),
- )
+ Box(Modifier.fillMaxSize()) {
+ AppScreensaverContent(
+ currentItem = currentItem,
+ showClock = screensaverPrefs.showClock,
+ duration = screensaverPrefs.duration.milliseconds,
+ animate = screensaverPrefs.animate,
+ modifier = Modifier.fillMaxSize(),
+ )
+ if (screensaverPrefs.dimEnabled) {
+ val alpha = screensaverPrefs.dimPercent / 100f
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = alpha)),
+ )
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt
index 69f2e745..6a691723 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt
@@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.PlaybackEffect
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.SeerrUser
import com.github.damontecres.wholphin.ui.components.ViewOptions
@@ -46,8 +47,9 @@ import java.util.UUID
ItemTrackModification::class,
SeerrServer::class,
SeerrUser::class,
+ RememberedTab::class,
],
- version = 33,
+ version = 35,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@@ -64,6 +66,8 @@ import java.util.UUID
AutoMigration(30, 31),
AutoMigration(31, 32),
AutoMigration(32, 33),
+ AutoMigration(33, 34),
+ AutoMigration(34, 35),
],
)
@TypeConverters(Converters::class)
@@ -81,6 +85,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun seerrServerDao(): SeerrServerDao
abstract fun playbackEffectDao(): PlaybackEffectDao
+
+ abstract fun rememberedTabDao(): RememberedTabDao
}
class Converters {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt
index ff568a37..490103b8 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt
@@ -27,6 +27,7 @@ sealed interface ExtrasItem {
val subtitle: String?
val isPlayed: Boolean
val playedPercentage: Double
+ val aspectRatio: Float?
/**
* Represents multiple extras of the same type
@@ -39,6 +40,7 @@ sealed interface ExtrasItem {
override val title: String,
override val subtitle: String,
override val isPlayed: Boolean,
+ override val aspectRatio: Float?,
) : ExtrasItem {
override val destination: Destination =
Destination.ItemGrid(
@@ -82,6 +84,8 @@ sealed interface ExtrasItem {
get() = item.played
override val playedPercentage
get() = item.data.userData?.playedPercentage ?: 0.0
+ override val aspectRatio: Float?
+ get() = item.aspectRatio
}
}
@@ -92,18 +96,18 @@ sealed interface ExtrasItem {
val ExtraType.stringRes: Int
get() =
when (this) {
- ExtraType.UNKNOWN -> R.string.other_extras
- ExtraType.CLIP -> R.string.clips
- ExtraType.TRAILER -> R.string.trailers
- ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes
- ExtraType.DELETED_SCENE -> R.string.deleted_scenes
- ExtraType.INTERVIEW -> R.string.interviews
- ExtraType.SCENE -> R.string.scenes
- ExtraType.SAMPLE -> R.string.samples
- ExtraType.THEME_SONG -> R.string.theme_songs
- ExtraType.THEME_VIDEO -> R.string.theme_videos
- ExtraType.FEATURETTE -> R.string.featurettes
- ExtraType.SHORT -> R.string.shorts
+ ExtraType.UNKNOWN -> R.string.other_extras_title
+ ExtraType.CLIP -> R.string.clips_title
+ ExtraType.TRAILER -> R.string.trailers_title
+ ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes_title
+ ExtraType.DELETED_SCENE -> R.string.deleted_scenes_title
+ ExtraType.INTERVIEW -> R.string.interviews_title
+ ExtraType.SCENE -> R.string.scenes_title
+ ExtraType.SAMPLE -> R.string.samples_title
+ ExtraType.THEME_SONG -> R.string.theme_songs_title
+ ExtraType.THEME_VIDEO -> R.string.theme_videos_title
+ ExtraType.FEATURETTE -> R.string.featurettes_title
+ ExtraType.SHORT -> R.string.shorts_title
}
/**
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
index 80a2c45f..c3afbc33 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
@@ -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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.StreamChoiceService
-import kotlinx.coroutines.Dispatchers
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
@@ -19,6 +19,9 @@ import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration
+/**
+ * Wrapper for [ItemPlaybackDao] to save [ItemPlayback] objects and also convert them into [ChosenStreams]
+ */
@Singleton
class ItemPlaybackRepository
@Inject
@@ -28,6 +31,13 @@ class ItemPlaybackRepository
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
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(
itemId: UUID,
item: BaseItem,
@@ -40,6 +50,11 @@ class ItemPlaybackRepository
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(
item: BaseItem,
itemPlayback: ItemPlayback?,
@@ -86,11 +101,14 @@ class ItemPlaybackRepository
}
}
+ /**
+ * Save the choice for a specific media source for an item
+ */
suspend fun savePlayVersion(
itemId: UUID,
sourceId: UUID,
): ItemPlayback? =
- withContext(Dispatchers.IO) {
+ withContext(WholphinDispatchers.IO) {
serverRepository.currentUser?.let { user ->
val itemPlayback =
ItemPlayback(
@@ -103,6 +121,11 @@ 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(
item: BaseItem,
itemPlayback: ItemPlayback?,
@@ -180,6 +203,9 @@ class ItemPlaybackRepository
return toSave.copy(rowId = id)
}
+ /**
+ * Wrapper for [ItemPlaybackDao.getTrackModifications] to get the [ItemTrackModification] for an item, if any
+ */
suspend fun getTrackModifications(
itemId: UUID,
trackIndex: Int,
@@ -188,6 +214,9 @@ class ItemPlaybackRepository
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
}
+ /**
+ * Create and save a [ItemTrackModification] via [ItemPlaybackDao.saveItem]
+ */
suspend fun saveTrackModifications(
itemId: UUID,
trackIndex: Int,
@@ -206,6 +235,9 @@ class ItemPlaybackRepository
}
}
+ /**
+ * Clear the user's source/stream choices for an item
+ */
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
Timber.d("deleteChosenStreams: %s", chosenStreams)
chosenStreams?.plc?.let {
@@ -219,6 +251,9 @@ class ItemPlaybackRepository
}
}
+/**
+ * Info about which media source and streams the user/app has chosen for playback
+ */
data class ChosenStreams(
val itemPlayback: ItemPlayback?,
val plc: PlaybackLanguageChoice?,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt
index 3c2250fb..f72eb3b7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt
@@ -9,6 +9,7 @@ import androidx.room.Update
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
import com.github.damontecres.wholphin.data.model.JellyfinUser
+import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Dao
@@ -56,6 +57,12 @@ interface JellyfinServerDao {
userId: UUID,
): JellyfinUser?
+ @Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
+ fun getUserFlow(
+ serverId: UUID,
+ userId: UUID,
+ ): Flow
+
@Query("DELETE FROM servers WHERE id = :serverId")
fun deleteServer(serverId: UUID)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt
new file mode 100644
index 00000000..13398228
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt
@@ -0,0 +1,22 @@
+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)
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
index 05cc9bd3..e6d9c468 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
@@ -9,13 +9,17 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.ui.toServerString
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin
@@ -56,7 +60,18 @@ class ServerRepository
val currentServer: JellyfinServer? get() = _current.value?.server
val currentServerFlow: Flow get() = _current.map { it?.server }
val currentUser: JellyfinUser? get() = _current.value?.user
- val currentUserFlow: Flow get() = _current.map { it?.user }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val currentUserFlow: Flow
+ 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
@@ -109,7 +124,7 @@ class ServerRepository
}.build()
}
val currentUser = CurrentUser(updatedServer, updatedUser)
- withContext(Dispatchers.Main) {
+ withContext(WholphinDispatchers.Main) {
_current.value = currentUser
_currentUserDto.value = userDto
}
@@ -222,7 +237,7 @@ class ServerRepository
suspend fun removeUser(user: JellyfinUser) {
if (current.value?.user?.id == user.id) {
- withContext(Dispatchers.Main) {
+ withContext(WholphinDispatchers.Main) {
_current.value = null
}
userPreferencesDataStore.updateData {
@@ -241,7 +256,7 @@ class ServerRepository
suspend fun removeServer(server: JellyfinServer) {
if (current.value?.server?.id == server.id) {
- withContext(Dispatchers.Main) {
+ withContext(WholphinDispatchers.Main) {
_current.value = null
}
userPreferencesDataStore.updateData {
@@ -298,6 +313,16 @@ class ServerRepository
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 {
fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt
new file mode 100644
index 00000000..446a64c0
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt
@@ -0,0 +1,287 @@
+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? = null,
+ val networkIds: List? = null,
+ // /api/v1/search/keyword
+ val keywordIds: List? = null,
+ val excludeKeywordIds: List? = 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? = null,
+ val certification: List? = 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>): 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>): 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 : FilterBy
+
+data object DiscoverMovieGenreFilter : DiscoverFilterBy> {
+ override val stringRes: Int = R.string.genres
+
+ override val supportMultiple: Boolean = true
+
+ override fun get(filter: DiscoverFilter): List? = filter.genreIds
+
+ override fun set(
+ value: List?,
+ filter: DiscoverFilter,
+ ): DiscoverFilter = filter.copy(genreIds = value)
+}
+
+data object DiscoverTvGenreFilter : DiscoverFilterBy> {
+ override val stringRes: Int = R.string.genres
+
+ override val supportMultiple: Boolean = true
+
+ override fun get(filter: DiscoverFilter): List? = filter.genreIds
+
+ override fun set(
+ value: List?,
+ filter: DiscoverFilter,
+ ): DiscoverFilter = filter.copy(genreIds = value)
+}
+
+data object DiscoverMovieStudiosFilter : DiscoverFilterBy> {
+ override val stringRes: Int = R.string.studios
+
+ override val supportMultiple: Boolean = true
+
+ override fun get(filter: DiscoverFilter): List? = filter.networkIds
+
+ override fun set(
+ value: List?,
+ filter: DiscoverFilter,
+ ): DiscoverFilter = filter.copy(networkIds = value)
+}
+
+data object DiscoverTvStudiosFilter : DiscoverFilterBy> {
+ override val stringRes: Int = R.string.studios
+
+ override val supportMultiple: Boolean = true
+
+ override fun get(filter: DiscoverFilter): List? = filter.networkIds
+
+ override fun set(
+ value: List?,
+ filter: DiscoverFilter,
+ ): DiscoverFilter = filter.copy(networkIds = value)
+}
+
+data object DiscoverTvStatusFilter : DiscoverFilterBy> {
+ override val stringRes: Int = R.string.production_status
+
+ override val supportMultiple: Boolean = true
+
+ override fun get(filter: DiscoverFilter): List? = filter.status
+
+ override fun set(
+ value: List?,
+ filter: DiscoverFilter,
+ ): DiscoverFilter = filter.copy(status = value)
+}
+
+data object DiscoverMovieContentRatingFilter : DiscoverFilterBy> {
+ override val stringRes: Int = R.string.official_rating
+
+ override val supportMultiple: Boolean = true
+
+ override fun get(filter: DiscoverFilter): List? = filter.certification
+
+ override fun set(
+ value: List?,
+ filter: DiscoverFilter,
+ ): DiscoverFilter = filter.copy(certification = value)
+}
+
+data object DiscoverTvContentRatingFilter : DiscoverFilterBy> {
+ override val stringRes: Int = R.string.official_rating
+
+ override val supportMultiple: Boolean = true
+
+ override fun get(filter: DiscoverFilter): List? = filter.certification
+
+ override fun set(
+ value: List?,
+ filter: DiscoverFilter,
+ ): DiscoverFilter = filter.copy(certification = value)
+}
+
+data object DiscoverRatingFilter : DiscoverFilterBy {
+ 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)
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt
index 37fd9514..b92d251f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt
@@ -77,24 +77,29 @@ val DefaultPlaylistItemsOptions =
)
/**
- * A way to filter libraries
- *
- * Gets and sets values within a [GetItemsFilter]
+ * Abstract way to filter data
*/
-sealed interface ItemFilterBy {
+sealed interface FilterBy {
@get:StringRes
val stringRes: Int
val supportMultiple: Boolean
- fun get(filter: GetItemsFilter): T?
+ fun get(filter: FilterType): DataType?
fun set(
- value: T?,
- filter: GetItemsFilter,
- ): GetItemsFilter
+ value: DataType?,
+ filter: FilterType,
+ ): FilterType
}
+/**
+ * A way to filter jellyfin libraries
+ *
+ * Gets and sets values within a [GetItemsFilter]
+ */
+sealed interface ItemFilterBy : FilterBy
+
data object GenreFilter : ItemFilterBy> {
override val stringRes: Int = R.string.genres
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt
new file mode 100644
index 00000000..6b30e9ff
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt
@@ -0,0 +1,27 @@
+package com.github.damontecres.wholphin.data.filter
+
+import com.github.damontecres.wholphin.data.model.GetItemsFilter
+
+interface SearchFilter> {
+ /**
+ * Returns how many of filters are actually being used in this [GetItemsFilter]
+ */
+ fun countFilters(filterOptions: List>): 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>): T {
+ var newFilter = this
+ filterOptions.forEach {
+ newFilter = it.set(null, newFilter as T)
+ }
+ return newFilter as T
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
index e6e70625..eefb001b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
@@ -5,13 +5,18 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString
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.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.music.artistsString
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.dot
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.joinNotBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.roundMinutes
@@ -19,11 +24,13 @@ import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
import com.github.damontecres.wholphin.ui.seriesProductionYears
import com.github.damontecres.wholphin.ui.timeRemaining
+import com.github.damontecres.wholphin.ui.toServerString
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.extensions.ticks
import java.util.Locale
import java.util.UUID
@@ -48,7 +55,7 @@ data class BaseItem(
get() = type.playable
override val sortName: String
- get() = data.sortName ?: data.name ?: ""
+ get() = data.alphabetSortName
val type get() = data.type
@@ -56,14 +63,14 @@ data class BaseItem(
val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name
- val subtitle
- get() =
- when (type) {
- BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
- BaseItemKind.SERIES -> data.seriesProductionYears
- BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ")
- else -> data.productionYear?.toString()
- }
+ val subtitle: String? by lazy {
+ when (type) {
+ BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ")
+ BaseItemKind.SERIES -> data.seriesProductionYears
+ BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ")
+ else -> data.productionYear?.toString()
+ }
+ }
val subtitleLong: String? by lazy {
if (type == BaseItemKind.EPISODE) {
@@ -71,7 +78,7 @@ data class BaseItem(
add(data.seasonEpisodePadded)
add(data.name)
add(data.premiereDate?.let { formatDateTime(it) })
- }.filterNotNull().joinToString(" - ")
+ }.joinNotBlank(" - ")
} else {
data.productionYear?.toString()
}
@@ -91,7 +98,12 @@ data class BaseItem(
val favorite get() = data.userData?.isFavorite ?: false
- val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks
+ val timeRemainingOrRuntime: Duration?
+ 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
@@ -100,7 +112,7 @@ data class BaseItem(
val ui =
BaseItemUi(
episodeCornerText =
- data.indexNumber?.let { "E$it" }
+ data.indexNumber?.let { formatEpisodeNumber(it) }
?: data.premiereDate?.let(::formatDateTime),
episodeUnplayedCornerText =
if (type == BaseItemKind.SERIES ||
@@ -108,7 +120,7 @@ data class BaseItem(
type == BaseItemKind.EPISODE ||
type == BaseItemKind.BOX_SET
) {
- data.indexNumber?.let { "E$it" }
+ data.indexNumber?.let { formatEpisodeNumber(it) }
?: data.userData
?.unplayedItemCount
?.takeIf { it > 0 }
@@ -136,16 +148,36 @@ data class BaseItem(
} else if (type == BaseItemKind.BOX_SET) {
data.productionYear?.let { add(it.toString()) }
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 {
data.productionYear?.let { add(it.toString()) }
}
data.runTimeTicks
?.ticks
?.roundMinutes
- ?.let { add(it.toString()) }
+ ?.takeIf { it > Duration.ZERO }
+ ?.let { add(WholphinApplication.instance.resources.formatDuration(it)) }
data.timeRemaining
?.roundMinutes
- ?.let { add("$it left") }
+ ?.let {
+ val resources = WholphinApplication.instance.resources
+ add(
+ resources.getString(
+ R.string.time_left,
+ resources.formatDuration(it),
+ ),
+ )
+ }
}
details.forEachIndexed { index, string ->
append(string)
@@ -197,25 +229,38 @@ data class BaseItem(
*/
fun destination(index: Int? = null): Destination {
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 =
// Redirect episodes & seasons to their series if possible
when (type) {
BaseItemKind.EPISODE -> {
- data.seasonId?.let { seasonId ->
+ val seasonId = data.seasonId
+ val seriesId = data.seriesId
+ if (seasonId != null && seriesId != null) {
Destination.SeriesOverview(
- data.seriesId!!,
+ seriesId,
BaseItemKind.SERIES,
SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber),
)
- } ?: Destination.MediaItem(this)
+ } else {
+ Destination.MediaItem(this)
+ }
}
BaseItemKind.SEASON -> {
- Destination.SeriesOverview(
- data.seriesId!!,
- BaseItemKind.SERIES,
- SeasonEpisodeIds(id, indexNumber, null, null),
- )
+ val seriesId = data.seriesId
+ if (seriesId != null) {
+ Destination.SeriesOverview(
+ seriesId,
+ BaseItemKind.SERIES,
+ SeasonEpisodeIds(id, indexNumber, null, null),
+ )
+ } else {
+ Destination.MediaItem(this)
+ }
}
BaseItemKind.TV_CHANNEL -> {
@@ -237,6 +282,16 @@ data class BaseItem(
}
}
+ BaseItemKind.AUDIO -> {
+ data.albumId?.let { albumId ->
+ Destination.MediaItem(
+ itemId = albumId,
+ type = BaseItemKind.MUSIC_ALBUM,
+ initialSongId = id,
+ )
+ } ?: Destination.MediaItem(this)
+ }
+
else -> {
Destination.MediaItem(this)
}
@@ -260,6 +315,9 @@ data class BaseItem(
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
data class BaseItemUi(
val episodeCornerText: String?,
@@ -283,6 +341,7 @@ fun createGenreDestination(
parentId: UUID,
parentName: String?,
includeItemTypes: List?,
+ collectionType: CollectionType,
) = Destination.FilteredCollection(
itemId = parentId,
parentType = BaseItemKind.GENRE,
@@ -298,9 +357,11 @@ fun createGenreDestination(
genres = listOf(genreId),
includeItemTypes = includeItemTypes,
),
- useSavedLibraryDisplayInfo = false,
+ useSavedLibraryDisplayInfo = true,
+ libraryDisplayInfoIdOverride = "${parentId.toServerString()}_genres",
),
recursive = true,
+ collectionType = collectionType,
)
fun createStudioDestination(
@@ -324,9 +385,11 @@ fun createStudioDestination(
studios = listOf(studioId),
includeItemTypes = includeItemTypes,
),
- useSavedLibraryDisplayInfo = false,
+ useSavedLibraryDisplayInfo = true,
+ libraryDisplayInfoIdOverride = "${parentId.toServerString()}_studios",
),
recursive = true,
+ collectionType = CollectionType.UNKNOWN,
)
val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt
index 3e07964c..71a300fb 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt
@@ -1,38 +1,30 @@
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.ImageType
import org.jellyfin.sdk.model.extensions.ticks
+import java.util.UUID
import kotlin.time.Duration
/**
* Represents a chapter within a video
*/
data class Chapter(
+ val itemId: UUID,
val name: String?,
val position: Duration,
- val imageUrl: String?,
+ val tag: String?,
+ val index: Int,
) {
companion object {
- fun fromDto(
- dto: BaseItemDto,
- api: ApiClient,
- ): List =
+ fun fromDto(dto: BaseItemDto): List =
dto.chapters
?.mapIndexed { index, chapter ->
Chapter(
- chapter.name,
- chapter.startPositionTicks.ticks,
- chapter.imageTag?.let {
- api.imageApi.getItemImageUrl(
- itemId = dto.id,
- imageType = ImageType.CHAPTER,
- tag = it,
- imageIndex = index,
- )
- },
+ itemId = dto.id,
+ name = chapter.name,
+ position = chapter.startPositionTicks.ticks,
+ tag = chapter.imageTag,
+ index = index,
)
}?.sortedBy { it.position }
.orEmpty()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt
index db03ff48..24d7d340 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt
@@ -56,12 +56,14 @@ enum class SeerrItemType(
enum class SeerrAvailability(
val status: Int,
) {
+ // https://github.com/seerr-team/seerr/blob/develop/server/constants/media.ts#L14
UNKNOWN(1),
PENDING(2),
PROCESSING(3),
PARTIALLY_AVAILABLE(4),
AVAILABLE(5),
- DELETED(6),
+ BLOCKLISTED(6),
+ DELETED(7),
;
companion object {
@@ -69,6 +71,23 @@ 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].
*/
@@ -84,6 +103,7 @@ data class DiscoverItem(
@Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?,
val posterUrl: String?,
val backDropUrl: String?,
+ val logoUrl: String?,
val jellyfinItemId: UUID?,
) : CardGridItem {
override val gridId: String get() = id.toString()
@@ -92,13 +112,7 @@ data class DiscoverItem(
val destination: Destination
get() {
- val jfType =
- when (type) {
- SeerrItemType.MOVIE -> BaseItemKind.MOVIE
- SeerrItemType.TV -> BaseItemKind.SERIES
- SeerrItemType.PERSON -> BaseItemKind.PERSON
- SeerrItemType.UNKNOWN -> null
- }
+ val jfType = type.baseItemKind
return if (jellyfinItemId != null && jfType != null) {
Destination.MediaItem(
itemId = jellyfinItemId,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt
index d0d32988..3746092b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt
@@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
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.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer
@@ -22,9 +23,13 @@ data class CollectionFolderFilter(
val nameOverride: String? = null,
val filter: GetItemsFilter = GetItemsFilter(),
/**
- * Whether to use the libray's saved sort & filter
+ * Whether to use the library's saved sort & filter
*/
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,
)
/**
@@ -134,6 +139,22 @@ data class GetItemsFilter(
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
*/
@@ -158,4 +179,5 @@ data class GetItemsFilter(
enum class GetItemsFilterOverride {
NONE,
PERSON,
+ ARTIST,
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
index 74a6c6d1..4914248f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
@@ -109,15 +109,7 @@ sealed interface HomeRowConfig {
@SerialName("Favorite")
data class Favorite(
val kind: BaseItemKind,
- override val viewOptions: HomeRowViewOptions =
- if (kind == BaseItemKind.EPISODE) {
- HomeRowViewOptions(
- heightDp = Cards.HEIGHT_EPISODE,
- aspectRatio = AspectRatio.WIDE,
- )
- } else {
- HomeRowViewOptions()
- },
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions)
}
@@ -243,7 +235,7 @@ data class HomeRowViewOptions(
val liveTvDefault =
HomeRowViewOptions(
- heightDp = 96,
+ heightDp = Cards.HEIGHT_LIVE_TV,
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.FIT,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt
index a357de4a..4c54311c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt
@@ -11,6 +11,8 @@ import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
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 kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
@@ -65,11 +67,17 @@ data class JellyfinUser(
val requireLogin: Boolean = false,
val lastUsed: ZonedDateTime? = null,
val uiLanguage: String? = null,
+ @Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(),
) {
val hasPin: Boolean get() = pin.isNotNullOrBlank()
+ val isProtected: Boolean get() = hasPin || requireLogin
+
override fun toString(): String =
- "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})"
+ "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " +
+ "accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " +
+ "requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " +
+ "appPreferences=$appPreferences"
}
/**
@@ -83,3 +91,13 @@ data class JellyfinServerUsers(
)
val users: List,
)
+
+@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,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt
index 3e3cbe18..9f10f58c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt
@@ -1,8 +1,5 @@
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 java.util.UUID
@@ -11,38 +8,13 @@ import java.util.UUID
*
* This is not the same thing as a Jellyfin server playlist
*/
-class Playlist(
- items: List,
- startIndex: Int = 0,
+data class Playlist(
+ val items: List,
) {
- 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 = 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 {
const val MAX_SIZE = 100
+
+ fun fromMedia(list: List) = Playlist(list.map { PlaylistItem.Media(it) })
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt
new file mode 100644
index 00000000..2fc6f702
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt
@@ -0,0 +1,24 @@
+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,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt
index a6886cf2..aafdebf7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt
@@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig
*/
enum class SeerrPermission(
private val flag: Int,
+ vararg val parents: SeerrPermission,
) {
// Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts
NONE(0),
@@ -17,28 +18,28 @@ enum class SeerrPermission(
REQUEST(32),
VOTE(64),
AUTO_APPROVE(128),
- AUTO_APPROVE_MOVIE(256),
- AUTO_APPROVE_TV(512),
+ AUTO_APPROVE_MOVIE(256, AUTO_APPROVE),
+ AUTO_APPROVE_TV(512, AUTO_APPROVE),
REQUEST_4K(1024),
- REQUEST_4K_MOVIE(2048),
- REQUEST_4K_TV(4096),
- REQUEST_ADVANCED(8192),
- REQUEST_VIEW(16384),
+ REQUEST_4K_MOVIE(2048, REQUEST_4K),
+ REQUEST_4K_TV(4096, REQUEST_4K),
+ REQUEST_ADVANCED(8192, MANAGE_REQUESTS),
+ REQUEST_VIEW(16384, MANAGE_REQUESTS),
AUTO_APPROVE_4K(32768),
- AUTO_APPROVE_4K_MOVIE(65536),
- AUTO_APPROVE_4K_TV(131072),
- REQUEST_MOVIE(262144),
- REQUEST_TV(524288),
+ AUTO_APPROVE_4K_MOVIE(65536, AUTO_APPROVE_4K),
+ AUTO_APPROVE_4K_TV(131072, AUTO_APPROVE_4K),
+ REQUEST_MOVIE(262144, REQUEST),
+ REQUEST_TV(524288, REQUEST),
MANAGE_ISSUES(1048576),
- VIEW_ISSUES(2097152),
- CREATE_ISSUES(4194304),
+ VIEW_ISSUES(2097152, MANAGE_ISSUES),
+ CREATE_ISSUES(4194304, MANAGE_ISSUES),
AUTO_REQUEST(8388608),
- AUTO_REQUEST_MOVIE(16777216),
- AUTO_REQUEST_TV(33554432),
- RECENT_VIEW(67108864),
- WATCHLIST_VIEW(134217728),
- MANAGE_BLACKLIST(268435456),
- VIEW_BLACKLIST(1073741824),
+ AUTO_REQUEST_MOVIE(16777216, AUTO_REQUEST),
+ AUTO_REQUEST_TV(33554432, AUTO_REQUEST),
+ RECENT_VIEW(67108864, MANAGE_REQUESTS),
+ WATCHLIST_VIEW(134217728, MANAGE_REQUESTS),
+ MANAGE_BLOCKLIST(268435456),
+ VIEW_BLOCKLIST(1073741824, MANAGE_BLOCKLIST),
;
internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag
@@ -48,5 +49,8 @@ enum class SeerrPermission(
* Check whether the user has the given permissions (or is an admin)
*/
fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean {
- return permission.hasPermission(this?.permissions ?: return false) || SeerrPermission.ADMIN.hasPermission(permissions)
+ if (this?.permissions == null) return false
+ return permission.hasPermission(permissions) ||
+ permission.parents.any { hasPermission(it) } ||
+ SeerrPermission.ADMIN.hasPermission(permissions)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
index 8149f8b6..43d2d53a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
@@ -53,7 +53,10 @@ sealed interface AppPreference {
value: T?,
): String? = null
- fun validate(value: T): PreferenceValidation = PreferenceValidation.Valid
+ fun validate(
+ prefs: Pref,
+ value: T,
+ ): PreferenceValidation = PreferenceValidation.Valid
companion object {
val SkipForward =
@@ -215,15 +218,16 @@ sealed interface AppPreference {
)
val HomeClickToPlay =
- AppSwitchPreference(
+ AppChoicePreference(
title = R.string.continue_watching_click_behavior,
defaultValue = false,
getter = { it.homePagePreferences.clickToPlay },
setter = { prefs, value ->
prefs.updateHomePagePreferences { clickToPlay = value }
},
- summaryOn = R.string.continue_watching_click_summary_on,
- summaryOff = R.string.continue_watching_click_summary_off,
+ displayValues = R.array.home_click_to_play_options,
+ indexToValue = { it != 0 },
+ valueToIndex = { if (it) 1 else 0 },
)
val PlayThemeMusic =
@@ -391,18 +395,25 @@ sealed interface AppPreference {
defaultValue = true,
getter = { it.playbackPreferences.overrides.ac3Supported },
setter = { prefs, value ->
- prefs.updatePlaybackOverrides { ac3Supported = value }
+ if (!value) prefs.updateExperimentalPreferences { preferAc3Surround = false }
+ prefs.updatePlaybackOverrides {
+ ac3Supported = value
+ }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
+
val DownMixStereo =
AppSwitchPreference(
title = R.string.downmix_stereo,
defaultValue = false,
getter = { it.playbackPreferences.overrides.downmixStereo },
setter = { prefs, value ->
- prefs.updatePlaybackOverrides { downmixStereo = value }
+ if (value) prefs.updateExperimentalPreferences { preferAc3Surround = false }
+ prefs.updatePlaybackOverrides {
+ downmixStereo = value
+ }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
@@ -572,6 +583,13 @@ sealed interface AppPreference {
destination = Destination.Settings(PreferenceScreenOption.ADVANCED),
)
+ val UserProfileSettings =
+ AppDestinationPreference(
+ title = R.string.override_user_profile_settings,
+ destination = Destination.UserAppPreferences,
+ summary = R.string.override_user_profile_settings_summary,
+ )
+
val SkipIntros =
AppChoicePreference(
title = R.string.skip_intro_behavior,
@@ -644,6 +662,20 @@ sealed interface AppPreference {
destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS),
)
+ val DpadSeekModePref =
+ AppChoicePreference(
+ 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 =
AppChoicePreference(
title = R.string.global_content_scale,
@@ -1106,6 +1138,7 @@ val basicPreferences =
AppPreference.CustomizeHome,
AppPreference.UserPinnedNavDrawerItems,
AppPreference.UserInterfaceLanguage,
+ AppPreference.UserProfileSettings,
),
),
PreferenceGroup(
@@ -1208,6 +1241,7 @@ val advancedPreferences =
AppPreference.CinemaMode,
AppPreference.GlobalContentScale,
AppPreference.SkipSegments,
+ AppPreference.DpadSeekModePref,
AppPreference.MaxBitrate,
AppPreference.RefreshRateSwitching,
AppPreference.ResolutionSwitching,
@@ -1266,8 +1300,16 @@ val advancedPreferences =
AppPreference.DebugLogging,
AppPreference.ImageDiskCacheSize,
AppPreference.ClearImageCache,
+ ExperimentalPreference.Enable,
AppPreference.OssLicenseInfo,
),
+ conditionalPreferences =
+ listOf(
+ ConditionalPreferences(
+ condition = { it.experimentalPreferences.enabled },
+ preferences = listOf(ExperimentalPreference.ExperimentalSettings),
+ ),
+ ),
),
)
}
@@ -1296,6 +1338,14 @@ val screensaverPreferences =
ScreensaverPreference.Start,
),
),
+ PreferenceGroup(
+ title = R.string.dim_screen,
+ preferences =
+ listOf(
+ ScreensaverPreference.DimToggle,
+ ScreensaverPreference.DimPercentage,
+ ),
+ ),
)
data class AppSwitchPreference(
@@ -1303,11 +1353,16 @@ data class AppSwitchPreference(
override val defaultValue: Boolean,
override val getter: (prefs: Pref) -> Boolean,
override val setter: (prefs: Pref, value: Boolean) -> Pref,
- val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid },
+ val validator: (prefs: Pref, value: Boolean) -> PreferenceValidation = { _, _ -> PreferenceValidation.Valid },
@param:StringRes val summary: Int? = null,
@param:StringRes val summaryOn: Int? = null,
@param:StringRes val summaryOff: Int? = null,
) : AppPreference {
+ override fun validate(
+ prefs: Pref,
+ value: Boolean,
+ ): PreferenceValidation = validator.invoke(prefs, value)
+
override fun summary(
context: Context,
value: Boolean?,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
index ddac936a..c6dcd8ec 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
@@ -55,6 +55,7 @@ class AppPreferencesSerializer
AppPreference.RefreshRateSwitching.defaultValue
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
cinemaMode = AppPreference.CinemaMode.defaultValue
+ dpadSeekMode = AppPreference.DpadSeekModePref.defaultValue
overrides =
PlaybackOverrides
@@ -174,6 +175,16 @@ class AppPreferencesSerializer
showLyrics = true
showAlbumArt = true
}.build()
+
+ experimentalPreferences =
+ ExperimentalPreferences
+ .newBuilder()
+ .apply {
+ enabled = false
+ videoTunnelingEnabled = false
+ preferAc3Surround = ExperimentalPreference.PreferAc3ForSurround.defaultValue
+ disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
+ }.build()
}.build()
override suspend fun readFrom(input: InputStream): AppPreferences {
@@ -252,6 +263,11 @@ inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder
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() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt
new file mode 100644
index 00000000..f18df846
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt
@@ -0,0 +1,96 @@
+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(
+ 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(
+ title = R.string.experimental_settings,
+ destination = Destination.Settings(PreferenceScreenOption.EXPERIMENTAL),
+ )
+
+ val VideoTunneling =
+ AppSwitchPreference(
+ 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(
+ 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(
+ 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 ExperimentalPreferences.get(block: ExperimentalPreferences.() -> T): T? = if (enabled) block.invoke(this) else null
+
+fun ExperimentalPreferences.enabled(block: ExperimentalPreferences.() -> Boolean): Boolean = enabled && block.invoke(this)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt
index 0000878b..478bffb6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt
@@ -1,5 +1,19 @@
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.Companion.milliseconds
@@ -10,3 +24,48 @@ val PlaybackPreferences.skipBackOnResume: Duration?
} else {
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
+ },
+ )
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt
index 1e1ad91c..04fb4a63 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt
@@ -176,6 +176,37 @@ object ScreensaverPreference {
},
)
+ val DimToggle =
+ AppSwitchPreference(
+ 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(
+ 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 =
AppClickablePreference(
title = R.string.start_screensaver,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt
index 35da376a..c90cd00a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt
@@ -1,5 +1,6 @@
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.UserConfiguration
@@ -8,6 +9,7 @@ import org.jellyfin.sdk.model.api.UserConfiguration
*/
data class UserPreferences(
val appPreferences: AppPreferences,
+ val userPreferences: JellyfinUserPreferences?,
)
val DefaultUserConfiguration =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt
new file mode 100644
index 00000000..31d0e98c
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt
@@ -0,0 +1,70 @@
+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(
+ title = R.string.preferred_audio_language,
+ getter = { },
+ setter = { prefs, _ -> prefs },
+ )
+
+ val PreferredSubtitleLang =
+ AppClickablePreference(
+ title = R.string.preferred_subtitle_language,
+ summary = null,
+ getter = { },
+ setter = { prefs, _ -> prefs },
+ )
+
+ val SubtitleModePref =
+ AppChoicePreference(
+ 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,
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
index 281eddc2..4910110a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
@@ -6,12 +6,18 @@ import android.os.Build
import androidx.core.content.edit
import androidx.datastore.core.DataStore
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.model.JellyfinUser
+import com.github.damontecres.wholphin.data.model.RememberedTab
import com.github.damontecres.wholphin.preferences.AppPreference
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.update
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.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
@@ -25,12 +31,16 @@ import com.github.damontecres.wholphin.preferences.updateSearchPreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
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.shouldEnableSeparateHdrToggle
import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.io.File
+import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -44,6 +54,8 @@ class AppUpgradeHandler
@param:ApplicationContext private val context: Context,
private val appPreferences: DataStore,
private val seerrServerDao: SeerrServerDao,
+ private val serverDao: JellyfinServerDao,
+ private val rememberedTabDao: RememberedTabDao,
) {
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
@@ -350,5 +362,85 @@ class AppUpgradeHandler
}
}
}
+
+ if (previous.isEqualOrBefore(Version.fromString("1.0.1-2-g0"))) {
+ Timber.i("Starting tab migration")
+ val users = mutableMapOf()
+ 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
+ }
+ }
+ }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt
index 1bf34929..8025a6f6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt
@@ -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.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.BackdropStyle
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -55,7 +55,7 @@ class BackdropService
* Update the backdrop to use the specified item
*/
suspend fun submit(item: BaseItem) =
- withContext(Dispatchers.IO) {
+ withContext(WholphinDispatchers.IO) {
val imageUrl =
if (item.type == BaseItemKind.GENRE) {
item.imageUrlOverride
@@ -76,7 +76,7 @@ class BackdropService
suspend fun submit(
itemId: String,
imageUrl: String?,
- ) = withContext(Dispatchers.IO) {
+ ) = withContext(WholphinDispatchers.IO) {
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
_backdropFlow.update {
it.copy(
@@ -132,7 +132,7 @@ class BackdropService
}
suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
- withContext(Dispatchers.IO) {
+ withContext(WholphinDispatchers.IO) {
if (imageUrl.isNullOrBlank()) {
return@withContext ExtractedColors.DEFAULT
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt
index 382ac22e..ceb84695 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt
@@ -9,12 +9,12 @@ import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.google.common.cache.CacheBuilder
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@@ -56,7 +56,7 @@ class DatePlayedService
adjacentTo = item.id,
limit = 1,
)
- return scope.async(Dispatchers.IO) {
+ return scope.async(WholphinDispatchers.IO) {
val premiereDate = item.data.premiereDate
try {
val result =
@@ -92,7 +92,7 @@ class DatePlayedService
* the previous episode's last played timestamp, and the episode's premiere date
*/
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
- withContext(Dispatchers.IO) {
+ withContext(WholphinDispatchers.IO) {
val seriesId = item.data.seriesId
return@withContext if (seriesId != null) {
datePlayedCache
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt
index 4405092e..29df802c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt
@@ -1,12 +1,15 @@
package com.github.damontecres.wholphin.services
import android.content.Context
+import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
-import com.github.damontecres.wholphin.preferences.PlaybackPreferences
+import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
+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.createDeviceProfile
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -34,20 +37,17 @@ class DeviceProfileService
private var deviceProfile: DeviceProfile? = null
suspend fun getOrCreateDeviceProfile(
- prefs: PlaybackPreferences,
+ appPrefs: AppPreferences,
serverVersion: ServerVersion?,
): DeviceProfile =
- withContext(Dispatchers.Default) {
+ withContext(WholphinDispatchers.Default) {
+ val prefs = appPrefs.playbackPreferences
mutex.withLock {
val newConfig =
DeviceProfileConfiguration(
maxBitrate = prefs.maxBitrate.toInt(),
- isAC3Enabled = prefs.overrides.ac3Supported,
- downMixAudio = prefs.overrides.downmixStereo,
- assPlaybackMode = prefs.overrides.assPlaybackMode,
- pgsDirectPlay = prefs.overrides.directPlayPgs,
- dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
- decodeAv1 = prefs.overrides.decodeAv1,
+ overrides = prefs.overrides,
+ experimental = appPrefs.experimentalPreferences,
jellyfinTenEleven =
serverVersion != null && serverVersion >= ServerVersion(10, 11, 0),
)
@@ -57,12 +57,13 @@ class DeviceProfileService
createDeviceProfile(
mediaTest = mediaCodecCapabilitiesTest,
maxBitrate = newConfig.maxBitrate,
- isAC3Enabled = newConfig.isAC3Enabled,
- downMixAudio = newConfig.downMixAudio,
- assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
- pgsDirectPlay = newConfig.pgsDirectPlay,
- dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
+ isAC3Enabled = newConfig.overrides.ac3Supported,
+ downMixAudio = newConfig.overrides.downmixStereo,
+ assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
+ pgsDirectPlay = newConfig.overrides.directPlayPgs,
+ dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL,
decodeAv1 = prefs.overrides.decodeAv1,
+ preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround },
jellyfinTenEleven = newConfig.jellyfinTenEleven,
)
}
@@ -76,11 +77,7 @@ class DeviceProfileService
*/
data class DeviceProfileConfiguration(
val maxBitrate: Int,
- val isAC3Enabled: Boolean,
- val downMixAudio: Boolean,
- val assPlaybackMode: AssPlaybackMode,
- val pgsDirectPlay: Boolean,
- val dolbyVisionELDirectPlay: Boolean,
- val decodeAv1: Boolean,
+ val overrides: PlaybackOverrides,
+ val experimental: ExperimentalPreferences,
val jellyfinTenEleven: Boolean,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt
index e7770ff5..80a4e5ab 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt
@@ -5,6 +5,8 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.pluralRes
+import com.github.damontecres.wholphin.data.stringRes
+import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@@ -48,9 +50,10 @@ class ExtrasService
if (items.size == 1) {
val item = items.first()
val title =
- item.title ?: context.resources.getQuantityString(type.pluralRes, 1)
+ item.title.takeIf { it.isNotNullOrBlank() }
+ ?: context.resources.getQuantityString(type.pluralRes, 1)
val subtitle =
- if (item.title == null) {
+ if (item.title.isNotNullOrBlank()) {
context.resources.getQuantityString(type.pluralRes, 1)
} else {
null
@@ -64,14 +67,14 @@ class ExtrasService
imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY),
)
} else if (items.size > 1) {
- val title =
- context.resources.getQuantityString(type.pluralRes, items.size)
+ val title = context.resources.getString(type.stringRes)
val subtitle =
context.resources.getQuantityString(
R.plurals.items,
items.size,
items.size,
)
+ val itemForImage = items.random()
ExtrasItem.Group(
parentId = itemId,
type = type,
@@ -81,9 +84,10 @@ class ExtrasService
isPlayed = items.all { it.played },
imageUrl =
imageUrlService.getItemImageUrl(
- items.random(),
+ itemForImage,
ImageType.PRIMARY,
),
+ aspectRatio = itemForImage.aspectRatio,
)
} else {
null
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt
new file mode 100644
index 00000000..52734f20
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt
@@ -0,0 +1,270 @@
+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>(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 {
+ 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()
+ 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 {
+ 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<*, *>,
+ )
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
index 4288e337..3371f45e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
@@ -7,16 +7,19 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomePageSettings
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.createGenreDestination
import com.github.damontecres.wholphin.data.model.createStudioDestination
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.HomePagePreferences
-import com.github.damontecres.wholphin.ui.DefaultItemFields
-import com.github.damontecres.wholphin.ui.SlimItemFields
+import com.github.damontecres.wholphin.ui.AspectRatio
+import com.github.damontecres.wholphin.ui.Cards
+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.formatTypeName
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.toBaseItems
import com.github.damontecres.wholphin.ui.toServerString
@@ -26,15 +29,16 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.ui.util.StringStringProvider
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.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
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.GetStudiosRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
-import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -53,20 +57,22 @@ import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
-import org.jellyfin.sdk.api.client.extensions.userViewsApi
+import org.jellyfin.sdk.model.DateTime
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
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.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
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.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
-import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
import timber.log.Timber
@@ -239,7 +245,7 @@ class HomeSettingsService
settings.rows.mapIndexed { index, config ->
resolve(index, config)
}
- HomePageResolvedSettings(resolvedRows)
+ HomePageResolvedSettings(userId, resolvedRows)
} else {
createDefault(userId)
}
@@ -250,12 +256,15 @@ class HomeSettingsService
/**
* Resolve the settings and set them to be the current settings
*/
- suspend fun updateCurrent(settings: HomePageSettings) {
+ suspend fun updateCurrent(
+ userId: UUID,
+ settings: HomePageSettings,
+ ) {
val resolvedRows =
settings.rows.mapIndexed { index, config ->
resolve(index, config)
}
- val resolvedSettings = HomePageResolvedSettings(resolvedRows)
+ val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows)
currentSettings.update { resolvedSettings }
}
@@ -275,20 +284,25 @@ class HomeSettingsService
val includedIds =
libraries
- .mapIndexed { index, it ->
- val parentId = it.itemId
- val title = getRecentlyAddedTitle(it)
- if (it.collectionType == CollectionType.LIVETV) {
+ .mapIndexed { index, library ->
+ val parentId = library.itemId
+ val title = getRecentlyAddedTitle(library.name)
+ if (library.collectionType == CollectionType.LIVETV) {
HomeRowConfigDisplay(
id = index,
- title = ResStringProvider(R.string.live_tv),
+ title = ResStringProvider(R.string.watch_live),
config = HomeRowConfig.TvPrograms(),
)
} else {
+ val viewOptions = viewOptionsForCollectionType(library.collectionType)
HomeRowConfigDisplay(
id = index,
title = title,
- config = HomeRowConfig.RecentlyAdded(parentId),
+ config =
+ HomeRowConfig.RecentlyAdded(
+ parentId = parentId,
+ viewOptions = viewOptions,
+ ),
)
}
}
@@ -301,7 +315,7 @@ class HomeSettingsService
),
)
val rowConfig = continueWatchingRow + includedIds
- return HomePageResolvedSettings(rowConfig)
+ return HomePageResolvedSettings(userId, rowConfig)
}
/**
@@ -318,12 +332,10 @@ class HomeSettingsService
val userDto by api.userApi.getUserById(userId)
val config = userDto.configuration ?: DefaultUserConfiguration
val libraries =
- api.userViewsApi
- .getUserViews(userId = userId)
- .content.items
- .filter {
- it.collectionType in supportedHomeCollectionTypes &&
- it.id !in config.latestItemsExcludes
+ navDrawerService
+ .getAllUserLibraries(userId, userDto.tvAccess)
+ .filterNot {
+ it.itemId in config.latestItemsExcludes
}
return if (customPrefs.isNotEmpty()) {
@@ -367,7 +379,7 @@ class HomeSettingsService
if (userDto.tvAccess) {
HomeRowConfigDisplay(
id = id++,
- title = ResStringProvider(R.string.live_tv),
+ title = ResStringProvider(R.string.watch_live),
config = HomeRowConfig.TvPrograms(),
)
} else {
@@ -396,15 +408,21 @@ class HomeSettingsService
}
}
if (sectionType == HomeSectionType.LATEST_MEDIA) {
- libraries.map {
+ libraries.map { library ->
+ val viewOptions =
+ viewOptionsForCollectionType(library.collectionType)
HomeRowConfigDisplay(
id = id++,
title =
ResArgStringProvider(
R.string.recently_added_in,
- it.name ?: "",
+ library.name ?: "",
+ ),
+ config =
+ HomeRowConfig.RecentlyAdded(
+ parentId = library.itemId,
+ viewOptions = viewOptions,
),
- config = HomeRowConfig.RecentlyAdded(it.id),
)
}
} else if (config != null) {
@@ -413,7 +431,7 @@ class HomeSettingsService
null
}
}.flatten()
- HomePageResolvedSettings(rowConfigs)
+ HomePageResolvedSettings(userId, rowConfigs)
} else {
null
}
@@ -503,8 +521,8 @@ class HomeSettingsService
is HomeRowConfig.Favorite -> {
val name =
ResProviderStringProvider(
- R.string.favorite_items,
- ResStringProvider(favoriteOptions[config.kind]!!),
+ R.string.favorite_items_title,
+ ResStringProvider(formatTypeName(config.kind)),
)
HomeRowConfigDisplay(id, name, config)
}
@@ -520,7 +538,7 @@ class HomeSettingsService
is HomeRowConfig.TvPrograms -> {
HomeRowConfigDisplay(
id = id,
- title = ResStringProvider(R.string.live_tv),
+ title = ResStringProvider(R.string.watch_live),
config,
)
}
@@ -550,8 +568,10 @@ class HomeSettingsService
): StringProvider =
try {
api.userLibraryApi
- .getItem(itemId = itemId)
- .content.name
+ .getItem(
+ userId = serverRepository.currentUser?.id,
+ itemId = itemId,
+ ).content.name
?.let {
if (stringRes == null) {
StringStringProvider(it)
@@ -592,6 +612,7 @@ class HomeSettingsService
items = resume,
viewOptions = row.viewOptions,
rowType = row,
+ showViewMore = resume.size >= limit,
)
}
@@ -611,6 +632,7 @@ class HomeSettingsService
items = nextUp,
viewOptions = row.viewOptions,
rowType = row,
+ showViewMore = nextUp.size >= limit,
)
}
@@ -631,17 +653,14 @@ class HomeSettingsService
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
+ val combined = latestNextUpService.buildCombined(resume, nextUp)
Success(
title = ResStringProvider(R.string.continue_watching),
- items =
- latestNextUpService
- .buildCombined(
- resume,
- nextUp,
- ).take(limit),
+ items = combined.take(limit),
viewOptions = row.viewOptions,
rowType = row,
+ showViewMore = combined.size >= limit,
)
}
@@ -693,6 +712,9 @@ class HomeSettingsService
listOf(it)
}
},
+ collectionType =
+ library?.collectionType
+ ?: CollectionType.UNKNOWN,
),
)
}
@@ -702,6 +724,7 @@ class HomeSettingsService
genres,
viewOptions = row.viewOptions,
rowType = row,
+ showViewMore = genres.size >= limit,
)
}
@@ -753,17 +776,16 @@ class HomeSettingsService
title,
studios,
viewOptions = row.viewOptions,
+ showViewMore = studios.size >= limit,
)
}
is HomeRowConfig.RecentlyAdded -> {
- val library =
- libraries
- .firstOrNull { it.itemId == row.parentId }
- val title = getRecentlyAddedTitle(library)
+ val library = libraries.firstOrNull { it.itemId == row.parentId }
+ val title = getRecentlyAddedTitle(library?.name)
val request =
GetLatestMediaRequest(
- fields = SlimItemFields,
+ fields = library.itemFields,
imageTypeLimit = 1,
parentId = row.parentId,
groupItems = true,
@@ -781,18 +803,16 @@ class HomeSettingsService
it,
row.viewOptions,
rowType = row,
+ showViewMore = it.size >= limit,
)
}
latest
}
is HomeRowConfig.RecentlyReleased -> {
- val name =
- libraries
- .firstOrNull { it.itemId == row.parentId }
- ?.name
+ val library = libraries.firstOrNull { it.itemId == row.parentId }
val title =
- name?.let {
+ library?.name?.let {
ResArgStringProvider(R.string.recently_released_in, it)
} ?: ResStringProvider(R.string.recently_released)
val request =
@@ -811,7 +831,7 @@ class HomeSettingsService
SortOrder.ASCENDING,
SortOrder.DESCENDING,
),
- fields = DefaultItemFields,
+ fields = library.itemFields,
recursive = true,
maxPremiereDate = LocalDateTime.now(),
isUnaired = false,
@@ -835,11 +855,13 @@ class HomeSettingsService
it,
row.viewOptions,
rowType = row,
+ showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.ByParent -> {
+ val library = libraries.firstOrNull { it.itemId == row.parentId }
val request =
GetItemsRequest(
userId = userDto.id,
@@ -848,27 +870,44 @@ class HomeSettingsService
sortBy =
row.sort?.let {
buildList {
- add(it.sort)
- if (it.sort != ItemSortBy.SORT_NAME) {
+ if (it.sort == ItemSortBy.RANDOM) {
add(ItemSortBy.SORT_NAME)
+ add(ItemSortBy.RANDOM)
+ } else {
+ add(it.sort)
+ if (it.sort != ItemSortBy.SORT_NAME) {
+ add(ItemSortBy.SORT_NAME)
+ }
}
}
},
sortOrder =
row.sort?.let {
buildList {
- add(it.direction)
- if (it.sort != ItemSortBy.SORT_NAME) {
+ if (it.sort == ItemSortBy.RANDOM) {
add(SortOrder.ASCENDING)
+ add(it.direction)
+ } else {
+ add(it.direction)
+ if (it.sort != ItemSortBy.SORT_NAME) {
+ add(SortOrder.ASCENDING)
+ }
}
}
},
limit = limit,
- fields = DefaultItemFields,
+ fields = library.itemFields,
)
+ // Not using getItemName because we want to throw the 404
val title =
- getItemName(null, row.parentId, ResStringProvider(R.string.collection))
+ api.userLibraryApi
+ .getItem(
+ userId = serverRepository.currentUser?.id,
+ itemId = row.parentId,
+ ).content.name
+ ?.let { StringStringProvider(it) }
+ ?: ResStringProvider(R.string.collection)
if (usePaging) {
ApiRequestPager(
api,
@@ -888,6 +927,7 @@ class HomeSettingsService
it,
row.viewOptions,
rowType = row,
+ showViewMore = it.size >= limit,
)
}
}
@@ -925,6 +965,7 @@ class HomeSettingsService
it,
row.viewOptions,
rowType = row,
+ showViewMore = it.size >= limit,
)
}
}
@@ -932,61 +973,87 @@ class HomeSettingsService
is HomeRowConfig.Favorite -> {
val title =
ResProviderStringProvider(
- R.string.favorite_items,
- ResStringProvider(favoriteOptions[row.kind]!!),
+ R.string.favorite_items_title,
+ ResStringProvider(formatTypeName(row.kind)),
)
- if (row.kind == BaseItemKind.PERSON) {
- val request =
- GetPersonsRequest(
- userId = userDto.id,
- limit = limit,
- fields = DefaultItemFields,
- isFavorite = true,
- enableImages = true,
- enableImageTypes = listOf(ImageType.PRIMARY),
- )
- GetPersonsHandler
- .execute(api, request)
- .content.items
- .map { BaseItem(it, true) }
- .let {
- Success(
- title,
- it,
- row.viewOptions,
- )
+ val resultList =
+ when (row.kind) {
+ BaseItemKind.PERSON -> {
+ val request =
+ GetPersonsRequest(
+ userId = userDto.id,
+ limit = limit,
+ fields = HomeItemFields,
+ isFavorite = true,
+ enableImages = true,
+ enableImageTypes = listOf(ImageType.PRIMARY),
+ )
+
+ 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)
+ .toBaseItems(row.viewOptions.useSeries)
+ }
+ }
+
+ else -> {
+ val fields =
+ if (row.kind == BaseItemKind.BOX_SET) {
+ HomeItemFieldsBoxSets
+ } else {
+ HomeItemFields
+ }
+ val request =
+ GetItemsRequest(
+ userId = userDto.id,
+ recursive = true,
+ limit = limit,
+ fields = fields,
+ includeItemTypes = listOf(row.kind),
+ isFavorite = true,
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetItemsRequestHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ GetItemsRequestHandler
+ .execute(api, request)
+ .toBaseItems(row.viewOptions.useSeries)
+ }
}
- } else {
- val request =
- GetItemsRequest(
- userId = userDto.id,
- recursive = true,
- limit = limit,
- fields = DefaultItemFields,
- includeItemTypes = listOf(row.kind),
- isFavorite = true,
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetItemsRequestHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- GetItemsRequestHandler
- .execute(api, request)
- .content.items
- .map { BaseItem(it, row.viewOptions.useSeries) }
- }.let {
- Success(
- title,
- it,
- row.viewOptions,
- rowType = row,
- )
}
+ resultList.let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
}
}
@@ -995,7 +1062,7 @@ class HomeSettingsService
GetRecordingsRequest(
userId = userDto.id,
isInProgress = true,
- fields = DefaultItemFields,
+ fields = HomeItemFields,
limit = limit,
enableImages = true,
enableUserData = true,
@@ -1020,41 +1087,53 @@ class HomeSettingsService
it,
row.viewOptions,
rowType = row,
+ showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.TvPrograms -> {
val request =
- GetRecommendedProgramsRequest(
+ GetProgramsDto(
userId = userDto.id,
- fields = DefaultItemFields,
+ fields = ProgramItemFields,
limit = limit,
enableUserData = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
imageTypeLimit = 1,
+ isAiring = true,
+ minEndDate = DateTime.now().plusMinutes(1),
)
- // paging not supported
- api.liveTvApi
- .getRecommendedPrograms(request)
- .content.items
- .map { BaseItem(it, row.viewOptions.useSeries) }
- .let {
- Success(
- ResStringProvider(R.string.live_tv),
- it,
- row.viewOptions,
- rowType = row,
- )
- }
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetProgramsDtoHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ api.liveTvApi
+ .getPrograms(request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ }.let {
+ Success(
+ ResStringProvider(R.string.watch_live),
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
}
is HomeRowConfig.TvChannels -> {
val request =
GetLiveTvChannelsRequest(
userId = userDto.id,
- fields = DefaultItemFields,
+ fields = HomeItemFields,
limit = limit,
enableImages = true,
)
@@ -1076,6 +1155,7 @@ class HomeSettingsService
it,
row.viewOptions,
rowType = row,
+ showViewMore = it.size >= limit,
)
}
}
@@ -1108,6 +1188,7 @@ class HomeSettingsService
suggestions.items,
row.viewOptions,
rowType = row,
+ showViewMore = suggestions.items.size >= limit,
)
}
@@ -1146,10 +1227,11 @@ data class HomeRowConfigDisplay(
* @see HomePageSettings
*/
data class HomePageResolvedSettings(
+ val userId: UUID,
val rows: List,
) {
companion object {
- val EMPTY = HomePageResolvedSettings(listOf())
+ val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList())
}
}
@@ -1179,10 +1261,56 @@ class UnsupportedHomeSettingsVersionException(
val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")
-fun getRecentlyAddedTitle(library: Library?): StringProvider =
- if (library?.isRecordingFolder == true) {
- ResStringProvider(R.string.recently_recorded)
- } else {
- library?.name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
- ?: ResStringProvider(R.string.recently_added)
+fun getRecentlyAddedTitle(name: String?): StringProvider =
+ name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
+ ?: ResStringProvider(R.string.recently_added)
+
+private val Library?.itemFields: List
+ get() =
+ if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
+ // Get child count to show in the header
+ HomeItemFieldsBoxSets
+ } else {
+ HomeItemFields
+ }
+
+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()
+ }
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
index 1dd642b0..6b71d683 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
@@ -223,11 +223,11 @@ class ImageUrlService
imageType = imageType,
maxWidth = maxWidth,
maxHeight = maxHeight,
- width = width,
- height = height,
+ width = width?.takeIf { it > 0 },
+ height = height?.takeIf { it > 0 },
quality = quality,
- fillWidth = fillWidth,
- fillHeight = fillHeight,
+ fillWidth = fillWidth?.takeIf { it > 0 },
+ fillHeight = fillHeight?.takeIf { it > 0 },
tag = tag,
format = format,
percentPlayed = percentPlayed,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt
new file mode 100644
index 00000000..2dd0cde5
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt
@@ -0,0 +1,221 @@
+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,
+ val addHomeToBackStack: Boolean = true,
+ ) : IntentResult
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt
index 2bc9943f..4ce6583f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt
@@ -8,8 +8,7 @@ package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
-import com.github.damontecres.wholphin.util.supportItemKinds
-import kotlinx.coroutines.Dispatchers
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
@@ -23,6 +22,7 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
+import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
@@ -60,16 +60,8 @@ class LatestNextUpService
userId = userId,
fields = SlimItemFields,
limit = limit,
- includeItemTypes =
- if (includeEpisodes) {
- supportItemKinds
- } else {
- supportItemKinds
- .toMutableSet()
- .apply {
- remove(BaseItemKind.EPISODE)
- }
- },
+ mediaTypes = listOf(MediaType.VIDEO),
+ excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null,
enableTotalRecordCount = false,
)
val items =
@@ -143,14 +135,14 @@ class LatestNextUpService
resume: List,
nextUp: List,
): List =
- withContext(Dispatchers.IO) {
+ withContext(WholphinDispatchers.IO) {
val start = System.currentTimeMillis()
val semaphore = Semaphore(3)
val deferred =
nextUp
.filter { it.data.seriesId != null }
.map { item ->
- async(Dispatchers.IO) {
+ async(WholphinDispatchers.IO) {
try {
semaphore.withPermit {
datePlayedService.getLastPlayed(item)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt
index 04706b94..01ed9dd2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt
@@ -16,12 +16,12 @@ import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@@ -92,7 +92,7 @@ class LatestNextUpSchedulerService
)
// Exposed for testing
- internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
+ internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO
init {
serverRepository.current.collectLatestIn(activity.lifecycleScope) { user ->
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt
new file mode 100644
index 00000000..d89916f0
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt
@@ -0,0 +1,94 @@
+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)
+ }
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
index c339a8dd..ec6e9d2c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
@@ -13,8 +13,11 @@ import androidx.media3.session.MediaSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.AudioItem
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.ui.DefaultItemFields
+import com.github.damontecres.wholphin.ui.DetailItemFields
+import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.seekBack
@@ -24,10 +27,11 @@ import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.PlaybackItemState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
+import com.github.damontecres.wholphin.util.WholphinDispatchers
+import com.github.damontecres.wholphin.util.profile.Codec
import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -35,6 +39,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -44,6 +49,7 @@ import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.api.BaseItemKind
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.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
@@ -72,6 +78,7 @@ class MusicService
private val playerFactory: PlayerFactory,
private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService,
+ private val userPreferencesService: UserPreferencesService,
) {
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
val state: StateFlow = _state
@@ -79,8 +86,15 @@ class MusicService
private val audioFormats by lazy { listOf(*supportedAudioCodecs) }
val player: Player by lazy {
+ val disableAudioOffload =
+ runBlocking {
+ userPreferencesService
+ .getCurrent()
+ .appPreferences.experimentalPreferences
+ .get { disableAudioOffload } ?: false
+ }
playerFactory
- .createAudioPlayer()
+ .createAudioPlayer(disableAudioOffload = disableAudioOffload)
.also {
it.addListener(MusicPlayerListener(it, _state))
}
@@ -92,6 +106,12 @@ class MusicService
private var activityTracker: TrackActivityPlaybackListener? = null
private var websocketJob: Job? = null
+ private suspend fun preferAc3Surround() =
+ userPreferencesService
+ .getCurrent()
+ .appPreferences.experimentalPreferences
+ .enabled { preferAc3Surround }
+
/**
* Start music playback
*
@@ -162,7 +182,7 @@ class MusicService
userId = serverRepository.currentUser?.id,
itemId = itemId,
limit = 200,
- fields = DefaultItemFields,
+ fields = DetailItemFields,
).content.items
.map { BaseItem(it, false) }
setQueue(items, false)
@@ -177,9 +197,9 @@ class MusicService
items: BlockingList,
startIndex: Int,
shuffled: Boolean,
- ) = withContext(Dispatchers.IO) {
+ ) = withContext(WholphinDispatchers.IO) {
Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled)
- withContext(Dispatchers.Main) {
+ withContext(WholphinDispatchers.Main) {
player.setMediaItems(emptyList())
player.shuffleModeEnabled = shuffled
}
@@ -195,11 +215,12 @@ class MusicService
shuffled: Boolean,
) {
Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled)
+ val preferAc3Surround = preferAc3Surround()
val mediaItems =
items
.filter { it.type == BaseItemKind.AUDIO }
- .map(::convert)
- withContext(Dispatchers.Main) {
+ .map { convert(it, preferAc3Surround) }
+ withContext(WholphinDispatchers.Main) {
player.setMediaItems(mediaItems)
player.shuffleModeEnabled = shuffled
updateQueueSize()
@@ -215,8 +236,9 @@ class MusicService
index: Int? = null,
) {
if (item.type == BaseItemKind.AUDIO) {
- val mediaItem = convert(item)
- withContext(Dispatchers.Main) {
+ val preferAc3Surround = preferAc3Surround()
+ val mediaItem = convert(item, preferAc3Surround)
+ withContext(WholphinDispatchers.Main) {
if (index != null) {
player.addMediaItem(index, mediaItem)
} else {
@@ -228,6 +250,8 @@ class MusicService
start()
}
}
+ } else {
+ Timber.w("Tried to add non-audio type %s: %s", item.type, item.id)
}
}
@@ -240,6 +264,7 @@ class MusicService
list: BlockingList,
startIndex: Int,
) = loading {
+ val preferAc3Surround = preferAc3Surround()
var remaining = startIndex
list.indices
.chunked(25)
@@ -250,7 +275,7 @@ class MusicService
list
.getBlocking(it)
?.takeIf { it.type == BaseItemKind.AUDIO }
- ?.let(::convert)
+ ?.let { convert(it, preferAc3Surround) }
} else {
Timber.v("Skipping $remaining")
remaining--
@@ -266,12 +291,36 @@ class MusicService
/**
* Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag
*/
- private fun convert(audio: BaseItem): MediaItem {
+ private fun convert(
+ 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 =
- api.universalAudioApi.getUniversalAudioStreamUrl(
- itemId = audio.id,
- container = audioFormats,
- )
+ 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(
+ itemId = audio.id,
+ container = audioFormats,
+ )
+ }
Timber.i("url=%s", url)
val imageUrl =
audio.data.albumId?.let { albumId ->
@@ -293,7 +342,7 @@ class MusicService
*/
private suspend fun updateQueueSize() {
// val ids =
-// withContext(Dispatchers.Default) {
+// withContext(WholphinDispatchers.Default) {
// (0..()
.onEach { message ->
message.data?.let {
- withContext(Dispatchers.Main) {
+ withContext(WholphinDispatchers.Main) {
when (it.command) {
PlaystateCommand.STOP -> {
stop()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt
index cbf2e955..6b8701ee 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt
@@ -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.NavPinType
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.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.showToast
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
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.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
@@ -52,35 +52,37 @@ class NavDrawerService
private val seerrServerRepository: SeerrServerRepository,
private val musicService: MusicService,
) {
- private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
+ private val _state = MutableStateFlow(NavDrawerItemState())
val state: StateFlow = _state
init {
// Handle updating the nav drawer when the user changes
- serverRepository.currentUserFlow
- .combine(serverRepository.currentUserDtoFlow) { user, userDto ->
- Pair(user, userDto)
- }.onEach { (user, userDto) ->
- Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id)
- _state.update {
- it.copy(
- items = emptyList(),
- moreItems = emptyList(),
- )
- }
+ combine(
+ serverRepository.currentUserFlow,
+ serverRepository.currentUserDtoFlow,
+ seerrServerRepository.active,
+ ) { user, userDto, discoverActive ->
+ Triple(user, userDto, discoverActive)
+ }.collectLatestIn(coroutineScope) { (user, userDto, discoverActive) ->
+ Timber.d(
+ "User updated: user=%s, userDto=%s, discoverActive=%s",
+ user?.id,
+ userDto?.id,
+ discoverActive,
+ )
+ try {
if (user != null && userDto != null && user.id == userDto.id) {
- updateNavDrawer(user, userDto)
+ updateNavDrawer(user, userDto, discoverActive)
+ } else {
+ _state.update { NavDrawerItemState() }
}
- }.catch { ex ->
+ } catch (ex: CancellationException) {
+ throw ex
+ } catch (ex: Exception) {
Timber.e(ex, "Error updating nav drawer")
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
coroutineScope.launchDefault {
@@ -132,13 +134,22 @@ class NavDrawerService
.content.items
val recordingFolders =
if (tvAccess) {
- api.liveTvApi
- .getRecordingFolders(userId = userId)
- .content.items
- .map { it.id }
- .toSet()
+ try {
+ api.liveTvApi
+ .getRecordingFolders(userId = userId)
+ .content.items
+ .map { it.id }
+ .toSet()
+ } catch (ex: InvalidStatusException) {
+ if (ex.status == 401 || ex.status == 403) {
+ Timber.w("Got HTTP %s querying for recording folders", ex.status)
+ emptySet()
+ } else {
+ throw ex
+ }
+ }
} else {
- setOf()
+ emptySet()
}
val libraries =
userViews
@@ -178,8 +189,13 @@ class NavDrawerService
suspend fun updateNavDrawer(
user: JellyfinUser,
userDto: UserDto,
+ discoverActive: Boolean,
) {
- val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
+ val builtins =
+ buildList {
+ add(NavDrawerItem.Favorites)
+ if (discoverActive) add(NavDrawerItem.Discover)
+ }
val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess)
val libraries =
allLibraries
@@ -204,7 +220,7 @@ class NavDrawerService
val allItems = builtins + libraries
val navDrawerPins =
- withContext(Dispatchers.IO) {
+ withContext(WholphinDispatchers.IO) {
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
}
@@ -229,21 +245,18 @@ class NavDrawerService
it.copy(
items = items,
moreItems = moreItems,
+ allLibraries = allLibraries,
)
}
}
}
data class NavDrawerItemState(
- val items: List,
- val moreItems: List,
- val discoverEnabled: Boolean,
- val nowPlayingEnabled: Boolean,
- val nowPlayingTitle: String?,
-) {
- companion object {
- val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null)
- }
-}
+ val items: List = emptyList(),
+ val moreItems: List = emptyList(),
+ val nowPlayingEnabled: Boolean = false,
+ val nowPlayingTitle: String? = null,
+ val allLibraries: List = emptyList(),
+)
val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
index 7f4886b4..cf4128fb 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
@@ -24,19 +24,21 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
import androidx.media3.exoplayer.video.VideoRendererEventListener
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.MediaExtensionStatus
-import com.github.damontecres.wholphin.preferences.PlaybackPreferences
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.util.mpv.MpvPlayer
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
import io.github.peerless2012.ass.media.kt.withAssMkvSupport
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
import io.github.peerless2012.ass.media.type.AssRenderType
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import timber.log.Timber
@@ -60,9 +62,10 @@ class PlayerFactory
suspend fun createVideoPlayer(
backend: PlayerBackend,
- prefs: PlaybackPreferences,
+ appPreferences: AppPreferences,
): PlayerCreation {
- withContext(Dispatchers.Main) {
+ val prefs = appPreferences.playbackPreferences
+ withContext(WholphinDispatchers.Main) {
if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release()
@@ -104,6 +107,7 @@ class PlayerFactory
WholphinRenderersFactory(context, decodeAv1)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode)
+
val mediaSourceFactory =
if (useLibAss) {
val renderType =
@@ -128,7 +132,15 @@ class PlayerFactory
extractorsFactory,
)
}
- val trackSelector = createTrackSelector()
+ val disableAudioOffload =
+ appPreferences.experimentalPreferences.get { disableAudioOffload } ?: false
+ val tunneling =
+ appPreferences.experimentalPreferences.get { videoTunnelingEnabled }
+ val trackSelector =
+ createTrackSelector(
+ tunneling = tunneling,
+ disableAudioOffload = disableAudioOffload,
+ )
ExoPlayer
.Builder(context)
@@ -138,7 +150,7 @@ class PlayerFactory
.build()
.apply {
assHandler?.init(this)
- withContext(Dispatchers.Main) {
+ withContext(WholphinDispatchers.Main) {
setAudioAttributes(
AudioAttributes
.Builder()
@@ -158,7 +170,10 @@ class PlayerFactory
return PlayerCreation(newPlayer, assHandler)
}
- fun createAudioPlayer(extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK): ExoPlayer {
+ fun createAudioPlayer(
+ disableAudioOffload: Boolean,
+ extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK,
+ ): ExoPlayer {
val rendererMode =
when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
@@ -176,7 +191,7 @@ class PlayerFactory
OkHttpDataSource.Factory(authOkHttpClient),
extractorsFactory,
)
- val trackSelector = createTrackSelector()
+ val trackSelector = createTrackSelector(disableAudioOffload = disableAudioOffload)
return ExoPlayer
.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
@@ -187,9 +202,10 @@ class PlayerFactory
it.setAudioAttributes(
AudioAttributes
.Builder()
+ .setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
- false,
+ true,
)
}
}
@@ -199,18 +215,33 @@ class PlayerFactory
.setConstantBitrateSeekingEnabled(true)
.setConstantBitrateSeekingAlwaysEnabled(true)
- private fun createTrackSelector() =
- DefaultTrackSelector(context).apply {
- setParameters(
- buildUponParameters()
- .setAudioOffloadPreferences(
- AudioOffloadPreferences
- .Builder()
- .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
- .build(),
- ),
- )
- }
+ private fun createTrackSelector(
+ tunneling: Boolean? = null,
+ disableAudioOffload: Boolean = false,
+ ) = DefaultTrackSelector(context).apply {
+ val offloadMode =
+ if (disableAudioOffload) {
+ AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED
+ } else {
+ AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED
+ }
+ setParameters(
+ buildUponParameters()
+ .apply {
+ tunneling?.let { setTunnelingEnabled(tunneling) }
+ }.setAudioOffloadPreferences(
+ AudioOffloadPreferences
+ .Builder()
+ .setAudioOffloadMode(offloadMode)
+ .build(),
+ ),
+ )
+ }
+
+ fun createMediaSession(player: Player) =
+ MediaSession
+ .Builder(context, player)
+ .build()
}
val Player.isReleased: Boolean
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt
index bf5a1269..b1a48753 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt
@@ -8,21 +8,20 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistInfo
import com.github.damontecres.wholphin.data.model.PlaylistItem
-import com.github.damontecres.wholphin.ui.DefaultItemFields
+import com.github.damontecres.wholphin.ui.DetailItemFields
import com.github.damontecres.wholphin.ui.components.baseItemKinds
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.toServerString
-import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
-import com.github.damontecres.wholphin.util.TransformList
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.CoroutineScope
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.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
@@ -66,7 +65,7 @@ class PlaylistCreator
GetEpisodesRequest(
seriesId = seriesId,
seasonId = seasonId,
- fields = DefaultItemFields,
+ fields = DetailItemFields,
startItemId = episodeId,
sortBy = if (shuffled) ItemSortBy.RANDOM else null,
limit = Playlist.MAX_SIZE,
@@ -78,7 +77,7 @@ class PlaylistCreator
.convertAndAddParts(false)
val startIndex =
episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0
- return Playlist(episodes, startIndex)
+ return Playlist(episodes.subList(startIndex, episodes.size))
}
/**
@@ -95,7 +94,7 @@ class PlaylistCreator
GetItemsRequest(
userId = serverRepository.currentUser?.id,
parentId = playlistId,
- fields = DefaultItemFields,
+ fields = DetailItemFields,
startIndex = startIndex,
limit = Playlist.MAX_SIZE,
sortBy = listOf(sortAndDirection.sort),
@@ -104,7 +103,7 @@ class PlaylistCreator
),
)
val items = GetItemsRequestHandler.execute(api, request).content.items
- return Playlist(items.convertAndAddParts(), 0)
+ return Playlist(items.convertAndAddParts())
}
private suspend fun createFromCollection(
@@ -136,7 +135,7 @@ class PlaylistCreator
excludeItemIds = listOf(item.id),
sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) },
sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) },
- fields = DefaultItemFields,
+ fields = DetailItemFields,
startIndex = startIndex,
limit = Playlist.MAX_SIZE,
excludeLocationTypes = listOf(LocationType.VIRTUAL),
@@ -147,7 +146,7 @@ class PlaylistCreator
.execute(api, request)
.content.items
.convertAndAddParts()
- return Playlist(items, 0)
+ return Playlist(items)
}
/**
@@ -202,28 +201,72 @@ class PlaylistCreator
BaseItemKind.SEASON -> {
val seriesId = item.seriesId
if (seriesId != null) {
- PlaylistCreationResult.Success(
- createFromEpisode(
- seriesId = seriesId,
- seasonId = item.id,
- episodeId = null,
- shuffled = shuffled,
- ),
- )
+ 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(
+ createFromEpisode(
+ seriesId = seriesId,
+ seasonId = item.id,
+ episodeId = null,
+ shuffled = shuffled,
+ ),
+ )
+ }
} else {
PlaylistCreationResult.Error(null, "Episode has no seriesId")
}
}
BaseItemKind.SERIES -> {
- PlaylistCreationResult.Success(
- createFromEpisode(
- seriesId = item.id,
- seasonId = null,
- episodeId = null,
- shuffled = shuffled,
- ),
- )
+ 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(
+ createFromEpisode(
+ seriesId = item.id,
+ seasonId = null,
+ episodeId = nextUp.id,
+ shuffled = shuffled,
+ ),
+ )
+ } else {
+ PlaylistCreationResult.Error(
+ null,
+ "Could not determine next up episode for series: " + item.id,
+ )
+ }
+ }
}
BaseItemKind.PLAYLIST -> {
@@ -263,7 +306,7 @@ class PlaylistCreator
}.let(::addAll)
}
}
- PlaylistCreationResult.Success(Playlist(list, 0))
+ PlaylistCreationResult.Success(Playlist(list))
}
// Not support yet
@@ -299,26 +342,49 @@ class PlaylistCreator
* Get the playlists on the server for a given media type
*/
suspend fun getServerPlaylists(
+ query: String,
mediaType: MediaType?,
- scope: CoroutineScope,
- ): List {
+ ): List {
+ val userId =
+ serverRepository.currentUser?.id ?: throw IllegalStateException("No user found")
val request =
GetItemsRequest(
+ searchTerm = query.takeIf { it.isNotBlank() },
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
mediaTypes = mediaType?.let { listOf(mediaType) },
recursive = true,
+ limit = 25,
+ sortBy = listOf(ItemSortBy.DATE_LAST_CONTENT_ADDED),
+ sortOrder = listOf(SortOrder.DESCENDING),
)
- val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
- return TransformList(pager) {
- it?.let {
- PlaylistInfo(
- id = it.id,
- name = it.name ?: context.getString(R.string.unknown),
- count = it.data.childCount ?: 0,
- mediaType = it.data.mediaType,
- )
+ val playlists = GetItemsRequestHandler.execute(api, request).content.items
+ return playlists
+ .mapNotNull { playlist ->
+ try {
+ val response = api.playlistsApi.getPlaylistUser(playlist.id, userId).content
+ if (response.canEdit) {
+ PlaylistInfo(
+ id = playlist.id,
+ name = playlist.name ?: context.getString(R.string.unknown),
+ count = playlist.childCount ?: 0,
+ mediaType = playlist.mediaType,
+ )
+ } else {
+ null
+ }
+ } catch (ex: InvalidStatusException) {
+ if (ex.status == 404) {
+ null
+ } else {
+ throw ex
+ }
+ }
}
- }
+// .sortedWith(
+// compareBy {
+// SearchRelevance.score(it.name, BaseItemKind.PLAYLIST, query)
+// }.thenBy { it.name },
+// )
}
suspend fun createServerPlaylist(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt
index 91df9518..174c103b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt
@@ -8,9 +8,9 @@ import android.os.Looper
import android.view.Display
import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.ui.showToast
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
@@ -38,7 +38,7 @@ class RefreshRateService
stream: MediaStream,
switchRefreshRate: Boolean,
switchResolution: Boolean,
- ) = withContext(Dispatchers.IO) {
+ ) = withContext(WholphinDispatchers.IO) {
if (!switchRefreshRate && !switchResolution) {
Timber.v("Not switching either refresh rate nor resolution")
return@withContext
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt
new file mode 100644
index 00000000..a3b5b795
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt
@@ -0,0 +1,34 @@
+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))
+ }
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt
index 6069c00a..c3fcb478 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt
@@ -1,6 +1,9 @@
package com.github.damontecres.wholphin.services
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.request.ImageRequest
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
@@ -10,10 +13,10 @@ import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -57,6 +60,7 @@ class ScreensaverService
val keepScreenOn = MutableStateFlow(false)
private var waitJob: Job? = null
+ private var dimJob: Job? = null
init {
userPreferencesService.flow
@@ -65,7 +69,14 @@ class ScreensaverService
val enabled =
prefs.appPreferences.interfacePreferences.screensaverPreference.enabled
keepScreenOnInternal(enabled)
- ScreensaverState(enabled, false, false, false)
+ ScreensaverState(
+ enabled = enabled,
+ enabledTemp = false,
+ active = false,
+ paused = false,
+ dimEnabled = prefs.appPreferences.interfacePreferences.screensaverPreference.dimEnabled,
+ dimActive = false,
+ )
}
}.launchIn(scope)
}
@@ -79,7 +90,10 @@ class ScreensaverService
// Timber.v("pulse")
_state.update {
if (!it.active) {
- it.copy(active = false)
+ it.copy(
+ active = false,
+ dimActive = false,
+ )
} else {
it
}
@@ -94,7 +108,30 @@ class ScreensaverService
.appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
delay(startDelay)
_state.update {
- it.copy(active = true)
+ it.copy(
+ 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)
}
}
}
@@ -109,6 +146,7 @@ class ScreensaverService
it.copy(
enabledTemp = true,
active = true,
+ dimActive = true,
)
}
}
@@ -121,9 +159,13 @@ class ScreensaverService
it.copy(
enabledTemp = false,
active = false,
+ dimActive = false,
)
}
- if (cancelJob) waitJob?.cancel()
+ if (cancelJob) {
+ waitJob?.cancel()
+ dimJob?.cancel()
+ }
}
/**
@@ -131,17 +173,24 @@ class ScreensaverService
*/
fun keepScreenOn(keep: Boolean) {
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)
- if (screensaverEnabled) {
+ if (screensaverEnabled || dimEnabled) {
// Page is requesting to keep screen on, so we don't wait to show the screensaver
_state.update {
- it.copy(active = false, paused = keep)
+ it.copy(
+ active = false,
+ dimActive = false,
+ paused = keep,
+ )
}
if (!keep) {
pulse()
}
- } else {
+ }
+ if (!screensaverEnabled) {
+ // If in-app screensaver is not enabled, send keep screen on to the OS
keepScreenOnInternal(keep)
}
}
@@ -224,7 +273,7 @@ class ScreensaverService
if (index > pager.lastIndex) index = 0
}
}
- }.flowOn(Dispatchers.Default).cancellable()
+ }.flowOn(WholphinDispatchers.Default).cancellable()
private suspend fun createPager(): ApiRequestPager {
val prefs =
@@ -245,6 +294,11 @@ class ScreensaverService
)
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(
@@ -252,6 +306,10 @@ data class ScreensaverState(
val enabledTemp: Boolean,
val active: Boolean,
val paused: Boolean,
+ val dimEnabled: Boolean = false,
+ val dimActive: Boolean = false,
) {
val show get() = (enabled || enabledTemp) && active && !paused
+
+ val showDim get() = dimEnabled && dimActive && !paused
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt
index d9a340c9..d60988a2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt
@@ -6,13 +6,21 @@ 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.MovieDetails
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.ServiceProfile
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.api.seerr.model.TvResult
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrAvailability
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.toLocalDate
import kotlinx.coroutines.flow.first
@@ -20,6 +28,7 @@ import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
+import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -115,21 +124,7 @@ class SeerrService
}
BaseItemKind.PERSON -> {
- 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
- }
+ personCredits(it)
}
else -> {
@@ -141,6 +136,50 @@ class SeerrService
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 =
+ 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? =
if (active.first()) {
item.data.providerIds
@@ -153,20 +192,15 @@ class SeerrService
null
}
- private suspend fun createImageUrl(
+ suspend fun createImageUrl(
imageType: ImageType,
path: String?,
mediaInfo: MediaInfo?,
+ backdropHeight: Int = 1080,
+ backdropWidth: Int = 1920,
): String? {
if (mediaInfo != null) {
- val itemId =
- if (mediaInfo.jellyfinMediaId.isNotNullOrBlank()) {
- mediaInfo.jellyfinMediaId.toUUIDOrNull()
- } else if (mediaInfo.jellyfinMediaId4k.isNotNullOrBlank()) {
- mediaInfo.jellyfinMediaId4k.toUUIDOrNull()
- } else {
- null
- }
+ val itemId = mediaInfo.jellyfinId
if (itemId != null) {
return imageUrlService.getItemImageUrl(
itemId = itemId,
@@ -174,21 +208,143 @@ class SeerrService
)
}
}
- val current = seerrServerRepository.current.firstOrNull() ?: return null
- val cacheImages = current.serverConfig.cacheImages == true
- val base =
- if (cacheImages) {
- current.server.url.removeSuffix("/") + "/imageproxy/tmdb"
+ return path?.takeIf { it.isNotNullOrBlank() }?.let {
+ val current = seerrServerRepository.current.firstOrNull() ?: return null
+ val cacheImages = current.serverConfig.cacheImages == true
+ val base =
+ if (cacheImages) {
+ current.server.url.removeSuffix("/") + "/imageproxy/tmdb"
+ } else {
+ "https://image.tmdb.org"
+ }
+ val prefix =
+ when (imageType) {
+ ImageType.PRIMARY -> "/t/p/w500"
+ ImageType.BACKDROP -> "/t/p/w${backdropWidth}_and_h${backdropHeight}_multi_faces"
+ else -> throw IllegalArgumentException("Image type not supported: $imageType")
+ }
+ 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 {
- "https://image.tmdb.org"
+ 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,
+ )
+ }
+ }
}
- val prefix =
- when (imageType) {
- ImageType.PRIMARY -> "/t/p/w500"
- ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces"
- else -> throw IllegalArgumentException("Image type not supported: $imageType")
+ }
+ return result
+ }
+
+ private fun setProfilesAndFolder(
+ profiles: List?,
+ rootFolders: List?,
+ activeProfileId: Int?,
+ activeDirectory: String?,
+ setter: (List, List) -> Unit,
+ ) {
+ val profiles =
+ profiles.orEmpty().mapNotNull {
+ if (it.id != null && it.name != null) {
+ SeerrProfile(it.id, it.name, activeProfileId == it.id)
+ } else {
+ null
+ }
}
- return "${base}${prefix}$path"
+ 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 =
@@ -216,7 +372,8 @@ class SeerrService
releaseDate = toLocalDate(movie.releaseDate),
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
- jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
+ logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo),
+ jellyfinItemId = movie.mediaInfo?.jellyfinId,
)
suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem =
@@ -232,7 +389,8 @@ class SeerrService
releaseDate = toLocalDate(movie.releaseDate),
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
- jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
+ logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo),
+ jellyfinItemId = movie.mediaInfo?.jellyfinId,
)
suspend fun createDiscoverItem(tv: TvResult): DiscoverItem =
@@ -248,7 +406,8 @@ class SeerrService
releaseDate = toLocalDate(tv.firstAirDate),
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
- jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
+ logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo),
+ jellyfinItemId = tv.mediaInfo?.jellyfinId,
)
suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem =
@@ -264,7 +423,8 @@ class SeerrService
releaseDate = toLocalDate(tv.firstAirDate),
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
- jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
+ logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo),
+ jellyfinItemId = tv.mediaInfo?.jellyfinId,
)
suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem =
@@ -280,7 +440,8 @@ class SeerrService
releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate),
posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo),
- jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
+ logoUrl = createImageUrl(ImageType.LOGO, null, search.mediaInfo),
+ jellyfinItemId = search.mediaInfo?.jellyfinId,
)
suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem =
@@ -293,7 +454,7 @@ class SeerrService
availability =
SeerrAvailability.from(credit.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
- releaseDate = toLocalDate(credit.firstAirDate),
+ releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate),
posterUrl =
createImageUrl(
ImageType.PRIMARY,
@@ -306,7 +467,8 @@ class SeerrService
credit.backdropPath,
credit.mediaInfo,
),
- jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
+ logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo),
+ jellyfinItemId = credit.mediaInfo?.jellyfinId,
)
suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem =
@@ -319,7 +481,7 @@ class SeerrService
availability =
SeerrAvailability.from(credit.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
- releaseDate = toLocalDate(credit.firstAirDate),
+ releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate),
posterUrl =
createImageUrl(
ImageType.PRIMARY,
@@ -332,6 +494,13 @@ class SeerrService
credit.backdropPath,
credit.mediaInfo,
),
- jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
+ logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo),
+ jellyfinItemId = credit.mediaInfo?.jellyfinId,
)
}
+
+val MediaInfo.jellyfinId: UUID?
+ get() = jellyfinMediaId4k?.toUUIDOrNull() ?: jellyfinMediaId?.toUUIDOrNull()
+
+val MediaInfo.jellyfinIdAsString: String?
+ get() = jellyfinMediaId4k ?: jellyfinMediaId
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt
index ec77c65c..708eb8b5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt
@@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.jellyfin.sdk.api.client.ApiClient
@@ -94,6 +95,8 @@ class ServerEventListener
.joinToString("\n")
showToast(context, toast, Toast.LENGTH_LONG)
}
+ }.catch { ex ->
+ Timber.e(ex, "Error in websocket subscription")
}.launchIn(activity.lifecycleScope)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt
similarity index 60%
rename from app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt
rename to app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt
index eeae86d9..97369c36 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt
@@ -2,11 +2,14 @@ package com.github.damontecres.wholphin.services
import android.content.Context
import android.os.Build
+import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.data.ServerRepository
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.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.google.protobuf.MessageLiteOrBuilder
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.Json
@@ -22,10 +25,10 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
- * Send media info to the server
+ * Send reports to the server such as media info or app logs
*/
@Singleton
-class MediaReportService
+class ServerReportService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@@ -45,45 +48,65 @@ class MediaReportService
/**
* Fetch the media info and send it to the server
*/
- fun sendReportFor(itemId: UUID) {
+ fun sendMediaReportFor(itemId: UUID) {
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
val item = api.userLibraryApi.getItem(itemId = itemId).content
- sendReportFor(item)
+ sendMediaReportFor(item)
}
}
/**
* Send the media report for the given item
*/
- suspend fun sendReportFor(item: BaseItemDto) {
+ suspend fun sendMediaReportFor(item: BaseItemDto) {
val sources =
item.mediaSources ?: api.userLibraryApi
.getItem(itemId = item.id)
.content.mediaSources
val sourcesJson = json.encodeToString(sources)
- val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences
+ val appPreferences = userPreferencesService.getCurrent().appPreferences
val serverVersion = serverRepository.currentServer?.serverVersion
val deviceProfile =
- deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion)
+ deviceProfileService.getOrCreateDeviceProfile(appPreferences, serverVersion)
val deviceProfileJson = json.encodeToString(deviceProfile)
val body =
- """
- Send media info
+ buildLogHeader("Send media info") + "\n\n" +
+ """
+ playbackPrefs=${appPreferences.playbackPreferences.toStringOneLine()}
+
+ 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
clientInfo=$clientInfo
+ flavor=${BuildConfig.FLAVOR}
deviceInfo=$deviceInfo
manufacturer=${Build.MANUFACTURER}
model=${Build.MODEL}
apiLevel=${Build.VERSION.SDK_INT}
-
- playbackPrefs=${playbackPrefs.toString().replace("\n", ", ").replace("\t", " ")}
-
- deviceProfile=$deviceProfileJson
-
- mediaSources=$sourcesJson
""".trimIndent()
- Timber.w(body)
- val response by api.clientLogApi.logFile(body)
+ }
+
+ suspend fun sendAppLogs() {
+ val logcat = getLogCatLines().joinToString("\n") { it.text }
+ val header = buildLogHeader("Send App Logs")
+ Timber.w(header)
+ val response by api.clientLogApi.logFile(header + "\n\n" + logcat)
showToast(context, "Sent! Filename=${response.fileName}")
}
}
+
+private fun MessageLiteOrBuilder.toStringOneLine(): String = toString().replace("\n", ", ").replace("\t", " ")
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt
index fa42469e..abe39959 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt
@@ -27,9 +27,6 @@ class SetupNavigationManager
fun navigateTo(destination: SetupDestination) {
backStack[0] = destination
log()
- if (destination !is SetupDestination.AppContent) {
- navigationManager.reloadHome()
- }
}
private fun log() {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
index 3a3a3e9c..09450b34 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
@@ -5,7 +5,9 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
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.UserProfileSettings
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import org.jellyfin.sdk.model.api.BaseItemDto
@@ -135,8 +137,8 @@ class StreamChoiceService
val seriesLang =
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
// If the user has chosen a different language for the series, prefer that
- val audioLanguage = seriesLang ?: userConfig?.audioLanguagePreference
-
+ val audioLanguage =
+ seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
if (audioLanguage.isNotNullOrBlank()) {
val sorted =
candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels })
@@ -226,23 +228,17 @@ class StreamChoiceService
): MediaStream? {
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
return null
- } else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
+ }
+ val seriesLang =
+ playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
+ val subtitleLanguage =
+ seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
+ if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
// Client-side manual override: User selected "Only Forced" in player menu
- val seriesLang =
- playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
- val subtitleLanguage =
- (seriesLang ?: userConfig?.subtitleLanguagePreference)
- ?.takeIf { it.isNotNullOrBlank() }
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
} else if (itemPlayback?.subtitleIndexEnabled == true) {
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else {
- val seriesLang =
- playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
- val subtitleLanguage =
- (seriesLang ?: userConfig?.subtitleLanguagePreference)
- ?.takeIf { it.isNotNullOrBlank() }
-
val subtitleMode =
when {
playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
@@ -258,9 +254,17 @@ class StreamChoiceService
else -> {
// Fallback to the user's preference
- userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT
+ when (prefs.userPreferences?.subtitleMode) {
+ 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 =
candidates
.sortedWith(
@@ -277,11 +281,13 @@ class StreamChoiceService
SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull {
- it.language.equals(subtitleLanguage, true) ||
- it.language.isUnknown
+ // Prefer non-forced first
+ !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
+ } ?: candidates.firstOrNull {
+ it.language.equalsLangOrUnknown(subtitleLanguage)
}
} else {
- candidates.firstOrNull()
+ candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
}
}
@@ -371,3 +377,38 @@ private val String?.isUnknown: Boolean
this.equals("undetermined", true) ||
this.equals("mul", 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() }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt
index 0921a1be..eedbffc5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt
@@ -4,11 +4,11 @@ package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.ui.toServerString
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.mayakapps.kache.InMemoryKache
import com.mayakapps.kache.ObjectKache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.ExperimentalSerializationApi
@@ -43,7 +43,7 @@ class SuggestionsCache
private val memoryCache =
InMemoryKache(maxSize = 8) {
- creationScope = CoroutineScope(Dispatchers.IO)
+ creationScope = CoroutineScope(WholphinDispatchers.IO)
}
private fun cacheKey(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt
index 800d31fd..aa64058d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt
@@ -9,9 +9,9 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@@ -81,7 +81,7 @@ class SuggestionsWorker
.takeIf { it > 0 }
?: AppPreference.HomePageItems.defaultValue.toInt()
- val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
+ val context = WholphinDispatchers.IO.limitedParallelism(2, "fetchSuggestions")
if (requestedParentId != null && requestedItemKind != null) {
Timber.d(
"Fetching on-demand suggestions for parent=%s kind=%s",
@@ -128,7 +128,7 @@ class SuggestionsWorker
supervisorScope {
supportedViews
.map { view ->
- async(Dispatchers.IO) {
+ async(WholphinDispatchers.IO) {
runCatching {
Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind)
fetchAndCacheSuggestions(
@@ -293,7 +293,7 @@ class SuggestionsWorker
limit = freshLimit,
)
}
- withContext(Dispatchers.Default) {
+ withContext(WholphinDispatchers.Default) {
val contextual = contextualDeferred.await()
val random = randomDeferred.await()
val fresh = freshDeferred.await()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt
index e1b6cf1b..036ca505 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt
@@ -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.IoCoroutineScope
import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.profile.Codec
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
@@ -102,7 +102,7 @@ class ThemeSongPlayer
Codec.Audio.FLAC,
),
)
- withContext(Dispatchers.Main) {
+ withContext(WholphinDispatchers.Main) {
player.apply {
stop()
volume = volumeLevel
@@ -127,7 +127,7 @@ class ThemeSongPlayer
scope.launch {
mutex.withLock {
state.value.job?.cancelAndJoin()
- withContext(Dispatchers.Main) {
+ withContext(WholphinDispatchers.Main) {
Timber.v("Stopping theme song")
player.stop()
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt
index 6be37807..22df106b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt
@@ -34,7 +34,7 @@ class TrailerService
// TODO would be nice to clean up the trailer name
// ?.replace(item.name ?: "", "")
// ?.removePrefix(" - ")
- ?: context.getString(R.string.trailer)
+ ?: context.resources.getQuantityString(R.plurals.trailers, 1)
val subtitle =
when (url.toUri().host) {
"youtube.com", "www.youtube.com" -> "YouTube"
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
index b56d8a7d..8c2d84cb 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
@@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services
import android.Manifest
+import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Context
import android.content.Intent
@@ -21,8 +22,8 @@ import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.Version
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -61,8 +62,6 @@ class UpdateChecker
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
- private const val PERMISSION_REQUEST_CODE = 123456
-
private val NOTE_REGEX = Regex("")
val ACTIVE = BuildConfig.UPDATING_ENABLED
@@ -131,7 +130,7 @@ class UpdateChecker
suspend fun getRelease(version: Version): Release? {
val url =
"https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}"
- return withContext(Dispatchers.IO) {
+ return withContext(WholphinDispatchers.IO) {
val request =
Request
.Builder()
@@ -146,7 +145,7 @@ class UpdateChecker
* Get the latest released version
*/
suspend fun getLatestRelease(updateUrl: String): Release? =
- withContext(Dispatchers.IO) {
+ withContext(WholphinDispatchers.IO) {
val request =
Request
.Builder()
@@ -158,8 +157,8 @@ class UpdateChecker
private fun getRelease(request: Request): Release? {
return okHttpClient.newCall(request).execute().use {
- if (it.isSuccessful && it.body != null) {
- val result = Json.parseToJsonElement(it.body!!.string())
+ if (it.isSuccessful) {
+ val result = Json.parseToJsonElement(it.body.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name)
val publishedAt =
@@ -195,11 +194,12 @@ class UpdateChecker
/**
* Download and install an update
*/
+ @SuppressLint("RequestInstallPackagesPolicy")
suspend fun installRelease(
release: Release,
callback: DownloadCallback,
) {
- withContext(Dispatchers.IO) {
+ withContext(WholphinDispatchers.IO) {
cleanup()
val request =
Request
@@ -208,9 +208,9 @@ class UpdateChecker
.get()
.build()
okHttpClient.newCall(request).execute().use {
- if (it.isSuccessful && it.body != null) {
+ if (it.isSuccessful) {
Timber.v("Request successful for ${release.downloadUrl}")
- withContext(Dispatchers.Main) {
+ withContext(WholphinDispatchers.Main) {
callback.contentLength(it.body.contentLength())
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -230,7 +230,7 @@ class UpdateChecker
contentValues,
)
if (uri != null) {
- it.body!!.byteStream().use { input ->
+ it.body.byteStream().use { input ->
resolver.openOutputStream(uri).use { output ->
copyTo(input, output!!, callback = callback)
}
@@ -290,7 +290,7 @@ class UpdateChecker
downloadDir.mkdirs()
val targetFile = File(downloadDir, APK_NAME)
targetFile.outputStream().use { output ->
- response.body!!.byteStream().use { input ->
+ response.body.byteStream().use { input ->
copyTo(input, output, callback = callback)
}
}
@@ -374,7 +374,7 @@ data class Release(
Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"),
"#$1",
)
- // Remove the last line for full changelog since its just a link
+ // Remove the last line for full changelog since it's just a link
.replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "")
}
@@ -387,29 +387,30 @@ interface DownloadCallback {
suspend fun copyTo(
input: InputStream,
out: OutputStream,
- bufferSize: Int = 16 * 1024,
+ bufferSize: Int = 64 * 1024,
callback: DownloadCallback,
-): Long {
- var bytesCopied: Long = 0
- val buffer = ByteArray(bufferSize)
- var bytes = input.read(buffer)
- while (bytes >= 0) {
- out.write(buffer, 0, bytes)
- bytesCopied += bytes
- withContext(Dispatchers.Main) {
- callback.bytesDownloaded(bytesCopied)
+): Long =
+ withContext(WholphinDispatchers.IO) {
+ var bytesCopied: Long = 0
+ val buffer = ByteArray(bufferSize)
+ var bytes = input.read(buffer)
+ while (bytes >= 0) {
+ out.write(buffer, 0, bytes)
+ bytesCopied += bytes
+ withContext(WholphinDispatchers.Main) {
+ callback.bytesDownloaded(bytesCopied)
+ }
+ bytes = input.read(buffer)
}
- bytes = input.read(buffer)
+ return@withContext bytesCopied
}
- return bytesCopied
-}
fun getDownloadUrl(
assets: JsonArray,
debug: Boolean,
- supportedAbis: List = Build.SUPPORTED_ABIS.toList(),
+ supportedABIs: List = Build.SUPPORTED_ABIS.toList(),
): 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 preferredNames =
buildList {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
index 0fe6be1e..03b8846f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
@@ -4,8 +4,9 @@ import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
-import kotlinx.coroutines.flow.firstOrNull
-import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
@@ -19,13 +20,10 @@ class UserPreferencesService
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore,
) {
- val flow = preferencesDataStore.data.map { UserPreferences(it) }
-
- suspend fun getCurrent(): UserPreferences =
- serverRepository.currentUserDto!!.configuration.let { userConfig ->
- val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
- UserPreferences(
- appPrefs,
- )
+ val flow: Flow =
+ preferencesDataStore.data.combine(serverRepository.currentUserFlow) { appPrefs, user ->
+ UserPreferences(appPrefs, user?.appPreferences)
}
+
+ suspend fun getCurrent(): UserPreferences = flow.first()
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt
index 2565f077..536dfb40 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt
@@ -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.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
@@ -56,7 +56,7 @@ class UserSwitchListener
user.uiLanguage?.let { LocaleListCompat.forLanguageTags(it) }
?: LocaleListCompat.getEmptyLocaleList()
Timber.i("Switching locale to %s", localeList)
- withContext(Dispatchers.Main) {
+ withContext(WholphinDispatchers.Main) {
AppCompatDelegate.setApplicationLocales(localeList)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
index d1ed9eac..06607cf9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
@@ -1,18 +1,13 @@
package com.github.damontecres.wholphin.services.hilt
import android.content.Context
-import androidx.datastore.core.DataStore
import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
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.util.CoroutineContextApiClientFactory
-import com.github.damontecres.wholphin.util.ExceptionHandler
-import com.github.damontecres.wholphin.util.RememberTabManager
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -20,9 +15,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.android.androidDevice
@@ -49,21 +42,21 @@ annotation class AuthOkHttpClient
annotation class StandardOkHttpClient
/**
- * A [CoroutineScope] with [Dispatchers.IO]
+ * A [CoroutineScope] with [WholphinDispatchers.IO]
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoCoroutineScope
/**
- * A [CoroutineScope] with [Dispatchers.Default]
+ * A [CoroutineScope] with [WholphinDispatchers.Default]
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultCoroutineScope
/**
- * [Dispatchers.IO]
+ * [WholphinDispatchers.IO]
*
* @see IoCoroutineScope
*/
@@ -72,7 +65,7 @@ annotation class DefaultCoroutineScope
annotation class IoDispatcher
/**
- * [Dispatchers.Default]
+ * [WholphinDispatchers.Default]
*
* @see DefaultCoroutineScope
*/
@@ -160,55 +153,10 @@ object AppModule {
@Singleton
fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi()
- /**
- * Implementation of [RememberTabManager] which remembers by server, user, & item
- */
- @Provides
- @Singleton
- fun rememberTabManager(
- serverRepository: ServerRepository,
- appPreference: DataStore,
- @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
@Singleton
@IoDispatcher
- fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
+ fun ioDispatcher(): CoroutineDispatcher = WholphinDispatchers.IO
@Provides
@Singleton
@@ -220,7 +168,7 @@ object AppModule {
@Provides
@Singleton
@DefaultDispatcher
- fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
+ fun defaultDispatcher(): CoroutineDispatcher = WholphinDispatchers.Default
@Provides
@Singleton
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
index 62609628..29688664 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
@@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.PlaybackEffectDao
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.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences
@@ -43,7 +44,7 @@ object DatabaseModule {
).addMigrations(Migrations.Migrate2to3)
// .setQueryCallback({ sqlQuery, args ->
// Timber.v("sqlQuery=$sqlQuery, args=$args")
-// }, Dispatchers.IO.asExecutor())
+// }, WholphinDispatchers.IO.asExecutor())
.build()
@Provides
@@ -74,6 +75,10 @@ object DatabaseModule {
@Singleton
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
+ @Provides
+ @Singleton
+ fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
+
@Provides
@Singleton
fun userPreferencesDataStore(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
index 4a0e5dde..d3dbf4c7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
@@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
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.ui.SlimItemFields
import dagger.assisted.Assisted
@@ -226,27 +227,27 @@ class TvProviderWorker
setIntent(
Intent(context, MainActivity::class.java)
- .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
- .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
+ .putExtra(IntentService.INTENT_ITEM_ID, item.id.toString())
+ .putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName)
.apply {
if (item.type == BaseItemKind.EPISODE) {
putExtra(
- MainActivity.INTENT_SERIES_ID,
+ IntentService.INTENT_SERIES_ID,
dto.seriesId?.toString(),
)
putExtra(
- MainActivity.INTENT_SEASON_ID,
+ IntentService.INTENT_SEASON_ID,
dto.seasonId?.toString(),
)
dto.parentIndexNumber?.let {
putExtra(
- MainActivity.INTENT_SEASON_NUMBER,
+ IntentService.INTENT_SEASON_NUMBER,
it,
)
}
dto.indexNumber?.let {
putExtra(
- MainActivity.INTENT_EPISODE_NUMBER,
+ IntentService.INTENT_EPISODE_NUMBER,
it,
)
}
@@ -264,6 +265,7 @@ class TvProviderWorker
.getLatestMedia(
GetLatestMediaRequest(
fields = SlimItemFields,
+ includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
imageTypeLimit = 1,
parentId = null,
groupItems = true,
@@ -362,27 +364,27 @@ class TvProviderWorker
setIntent(
Intent(context, MainActivity::class.java)
- .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
- .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
+ .putExtra(IntentService.INTENT_ITEM_ID, item.id.toString())
+ .putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName)
.apply {
if (item.type == BaseItemKind.EPISODE) {
putExtra(
- MainActivity.INTENT_SERIES_ID,
+ IntentService.INTENT_SERIES_ID,
dto.seriesId?.toString(),
)
putExtra(
- MainActivity.INTENT_SEASON_ID,
+ IntentService.INTENT_SEASON_ID,
dto.seasonId?.toString(),
)
dto.parentIndexNumber?.let {
putExtra(
- MainActivity.INTENT_SEASON_NUMBER,
+ IntentService.INTENT_SEASON_NUMBER,
it,
)
}
dto.indexNumber?.let {
putExtra(
- MainActivity.INTENT_EPISODE_NUMBER,
+ IntentService.INTENT_EPISODE_NUMBER,
it,
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt
index e2c4f976..703182ba 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt
@@ -9,6 +9,7 @@ import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.network.CacheStrategy
+import coil3.network.ConnectivityChecker
import coil3.network.NetworkRequest
import coil3.network.NetworkResponse
import coil3.network.cachecontrol.CacheControlCacheStrategy
@@ -95,6 +96,8 @@ fun CoilConfig(
OkHttpNetworkFetcherFactory(
cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) },
callFactory = { client },
+ // Disable connectivity checker which may be unreliable for VPNs and/or some versions of Android
+ connectivityChecker = { ConnectivityChecker { true } },
),
)
}.build()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
index 2c633c35..312bc6cc 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
@@ -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.RowColumnSaver
import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
@@ -74,6 +74,8 @@ inline fun List.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? {
return if (index >= 0) index else null
}
+fun Iterable.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
*/
@@ -242,7 +244,7 @@ val BaseItemDto.timeRemaining: Duration?
get() =
userData?.playbackPositionTicks?.let {
if (it > 0) {
- runTimeTicks?.minus(it)?.ticks
+ runTimeTicks?.minus(it)?.ticks?.takeIf { it > Duration.ZERO }
} else {
null
}
@@ -359,34 +361,34 @@ suspend fun showToast(
context: Context,
text: CharSequence,
duration: Int,
-) = withContext(Dispatchers.Main) {
+) = withContext(WholphinDispatchers.Main) {
Toast.makeText(context, text, duration).show()
}
suspend fun showToast(
context: Context,
text: CharSequence,
-) = withContext(Dispatchers.Main) {
+) = withContext(WholphinDispatchers.Main) {
Toast.makeText(context, text, Toast.LENGTH_LONG).show()
}
/**
- * Launches a coroutine with [Dispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
+ * Launches a coroutine with [WholphinDispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
*/
fun CoroutineScope.launchIO(
context: CoroutineContext = ExceptionHandler(),
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit,
-): Job = launch(context = Dispatchers.IO + context, start = start, block = block)
+): Job = launch(context = WholphinDispatchers.IO + context, start = start, block = block)
/**
- * Launches a coroutine with [Dispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
+ * Launches a coroutine with [WholphinDispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
*/
fun CoroutineScope.launchDefault(
context: CoroutineContext = ExceptionHandler(),
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit,
-): Job = launch(context = Dispatchers.Default + context, start = start, block = block)
+): Job = launch(context = WholphinDispatchers.Default + context, start = start, block = block)
/**
* Converts a UUID to the format used server-side (ie without hyphens).
@@ -410,7 +412,7 @@ fun logTab(
ACRA.errorReporter.putCustomData("tabInfo", info)
}
-suspend fun onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
+suspend fun onMain(block: suspend CoroutineScope.() -> T) = withContext(WholphinDispatchers.Main, block)
fun Modifier.dimAndBlur(enabled: Boolean) =
this.ifElse(
@@ -425,6 +427,9 @@ fun Response.toBaseItems(
useSeriesForPrimary: Boolean,
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
+fun Response.toBaseItems(useSeriesForPrimary: Boolean = false) =
+ this.content.items.map { BaseItem(it, useSeriesForPrimary = useSeriesForPrimary) }
+
/**
* Check if this, coalescing nulls to zero, is greater than that
*/
@@ -446,3 +451,15 @@ fun Flow.collectLatestIn(
* Easy way to combine two flows into a [Pair]
*/
fun Flow.combinePair(flow: Flow): Flow> = combine(flow) { t1, t2 -> Pair(t1, t2) }
+
+/**
+ * Easy way to combine three flows into a [Triple]
+ */
+fun Flow.combineTriple(
+ flow: Flow,
+ flow2: Flow,
+): Flow> =
+ combine(flow) { t1, t2 -> Pair(t1, t2) }
+ .combine(flow2) { (t1, t2), t3 ->
+ Triple(t1, t2, t3)
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
index 7b64b1a4..282c161e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
@@ -1,5 +1,8 @@
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.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString
@@ -12,19 +15,36 @@ import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber
import java.time.LocalDate
import java.time.LocalDateTime
+import java.time.LocalTime
+import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.format.FormatStyle
+import java.util.Date
import java.util.Locale
+import kotlin.time.Duration
-private var timeFormatter: DateTimeFormatter =
- DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault())
+/**
+ * Format the time-of-day of [time] honouring the device's 12/24-hour setting and the current
+ * 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 getTimeFormatter(): DateTimeFormatter {
- if (timeFormatter.locale != Locale.getDefault()) {
- timeFormatter = timeFormatter.withLocale(Locale.getDefault())
- }
- return timeFormatter
+fun formatTime(
+ context: Context,
+ time: LocalTime,
+): String {
+ val instant =
+ time
+ .atDate(LocalDate.now())
+ .atZone(ZoneId.systemDefault())
+ .toInstant()
+ return DateFormat.getTimeFormat(context).format(Date.from(instant))
}
private var dateFormatter: DateTimeFormatter =
@@ -59,36 +79,74 @@ 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)
+}
+
/**
- * If the item has season & episode info, format as `S# E#`
+ * Format season & episode numbers using localized string resources.
+ * 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?
get() =
- if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) {
- "S$parentIndexNumber E$indexNumber-E$indexNumberEnd"
- } else if (parentIndexNumber != null && indexNumber != null) {
- "S$parentIndexNumber E$indexNumber"
- } else {
- null
- }
+ formatSeasonEpisode(
+ parentIndexNumber,
+ indexNumber,
+ indexNumberEnd,
+ )
/**
- * If the item has season & episode info, format padded as `S## E##`
+ * If the item has season & episode info, format padded as localized `S##E##`
*/
val BaseItemDto.seasonEpisodePadded: String?
get() =
- if (parentIndexNumber != null && indexNumber != null) {
- val season = parentIndexNumber?.toString()?.padStart(2, '0')
- val episode = indexNumber?.toString()?.padStart(2, '0')
- val endEpisode = indexNumberEnd?.toString()?.padStart(2, '0')
- if (endEpisode != null) {
- "S${season}E$episode-E$endEpisode"
- } else {
- "S${season}E$episode"
- }
- } else {
- null
- }
+ formatSeasonEpisode(
+ parentIndexNumber,
+ indexNumber,
+ indexNumberEnd,
+ padded = true,
+ )
val BaseItemDto.seriesProductionYears: String?
get() =
@@ -200,44 +258,64 @@ fun listToDotString(
}
}
+@get:StringRes
+val BaseItemKind.titleStringRes: Int
+ get() = formatTypeName(this)
+
@StringRes
fun formatTypeName(type: BaseItemKind): Int =
when (type) {
- BaseItemKind.MOVIE -> R.string.movies
- BaseItemKind.SERIES -> R.string.tv_shows
+ BaseItemKind.MOVIE -> R.string.movies_title
+ BaseItemKind.SERIES -> R.string.tv_shows_title
BaseItemKind.EPISODE -> R.string.episodes
BaseItemKind.VIDEO -> R.string.videos
BaseItemKind.PLAYLIST -> R.string.playlists
- BaseItemKind.PERSON -> R.string.people
+ BaseItemKind.PERSON -> R.string.people_title
BaseItemKind.BOX_SET -> R.string.collections
- BaseItemKind.AUDIO -> TODO()
+ BaseItemKind.AUDIO -> R.string.songs
BaseItemKind.CHANNEL -> R.string.channels
BaseItemKind.GENRE -> R.string.genres
BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels
- BaseItemKind.MUSIC_ALBUM -> TODO()
- BaseItemKind.MUSIC_ARTIST -> TODO()
- BaseItemKind.MUSIC_GENRE -> TODO()
- BaseItemKind.MUSIC_VIDEO -> TODO()
+ BaseItemKind.MUSIC_ALBUM -> R.string.albums
+ BaseItemKind.MUSIC_ARTIST -> R.string.artists
+ BaseItemKind.MUSIC_GENRE -> R.string.genres
+ BaseItemKind.MUSIC_VIDEO -> R.string.music_videos
BaseItemKind.PHOTO -> R.string.photos
- BaseItemKind.PHOTO_ALBUM -> TODO()
- BaseItemKind.PROGRAM -> TODO()
- BaseItemKind.RECORDING -> TODO()
+ BaseItemKind.PHOTO_ALBUM -> R.string.photo_albums
+ BaseItemKind.PROGRAM -> R.string.programs
+ BaseItemKind.RECORDING -> R.string.recordings
BaseItemKind.SEASON -> R.string.tv_seasons
BaseItemKind.STUDIO -> R.string.studios
- BaseItemKind.TRAILER -> R.string.trailers
+ BaseItemKind.TRAILER -> R.string.trailers_title
BaseItemKind.TV_CHANNEL -> R.string.channels
- BaseItemKind.TV_PROGRAM -> TODO()
- BaseItemKind.USER_ROOT_FOLDER -> TODO()
- BaseItemKind.USER_VIEW -> TODO()
- BaseItemKind.YEAR -> TODO()
- BaseItemKind.AGGREGATE_FOLDER -> TODO()
- BaseItemKind.AUDIO_BOOK -> TODO()
- BaseItemKind.BASE_PLUGIN_FOLDER -> TODO()
- BaseItemKind.BOOK -> TODO()
- BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO()
- BaseItemKind.COLLECTION_FOLDER -> TODO()
- BaseItemKind.FOLDER -> TODO()
- BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO()
- BaseItemKind.LIVE_TV_PROGRAM -> TODO()
- BaseItemKind.PLAYLISTS_FOLDER -> TODO()
+ BaseItemKind.TV_PROGRAM -> R.string.programs
+ BaseItemKind.USER_ROOT_FOLDER -> R.string.folders_title
+ BaseItemKind.USER_VIEW -> R.string.user_views
+ BaseItemKind.YEAR -> R.string.year
+ BaseItemKind.AGGREGATE_FOLDER -> R.string.folders_title
+ BaseItemKind.AUDIO_BOOK -> R.string.audio_books
+ BaseItemKind.BASE_PLUGIN_FOLDER -> R.string.folders_title
+ BaseItemKind.BOOK -> R.string.books
+ BaseItemKind.CHANNEL_FOLDER_ITEM -> R.string.folders_title
+ BaseItemKind.COLLECTION_FOLDER -> R.string.library
+ BaseItemKind.FOLDER -> R.string.folders_title
+ BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> R.string.playlists
+ BaseItemKind.LIVE_TV_PROGRAM -> R.string.programs
+ BaseItemKind.PLAYLISTS_FOLDER -> R.string.playlists
+ }
+
+/**
+ * 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)
+ }
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt
index ab0f69f8..2e57b407 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt
@@ -38,27 +38,25 @@ object AppColors {
val Purple = Color(147, 51, 234)
val Green = Color(74, 222, 128)
val Yellow = Color(234, 179, 8)
+ val Red = Color(204, 38, 38)
}
}
const val DEFAULT_PAGE_SIZE = 100
/**
- * The default [ItemFields] to fetch for most queries
+ * The [ItemFields] to fetch for detailed paged and playback
*/
-val DefaultItemFields =
+val DetailItemFields =
listOf(
- ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
- ItemFields.SEASON_USER_DATA,
- ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW,
ItemFields.TRICKPLAY,
ItemFields.SORT_NAME,
ItemFields.CHAPTERS,
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_SOURCE_COUNT,
- ItemFields.PARENT_ID,
ItemFields.CAN_DELETE,
+ ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
)
/**
@@ -66,27 +64,37 @@ val DefaultItemFields =
*/
val SlimItemFields =
listOf(
- ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
- ItemFields.SEASON_USER_DATA,
- ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW,
ItemFields.SORT_NAME,
ItemFields.MEDIA_SOURCE_COUNT,
- ItemFields.PARENT_ID,
+ ItemFields.CAN_DELETE,
+ )
+
+/**
+ * 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,
)
val PhotoItemFields =
- DefaultItemFields +
+ DetailItemFields +
listOf(
ItemFields.WIDTH,
ItemFields.HEIGHT,
)
+val ProgramItemFields = DetailItemFields + listOf(ItemFields.CHANNEL_INFO)
+
object Cards {
const val HEIGHT_2X3_DP = 172
val height2x3 = HEIGHT_2X3_DP.dp
const val HEIGHT_EPISODE = 128
+ const val HEIGHT_LIVE_TV = 96
val heightEpisode = HEIGHT_EPISODE.dp
val playedPercentHeight = 6.dp
val serverUserCircle = height2x3 * .75f
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt
index 8d00edfd..4536a364 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt
@@ -1,6 +1,5 @@
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
@@ -74,7 +73,7 @@ fun BannerCard(
val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
val fillHeight =
- remember(cardHeight) {
+ remember(cardHeight, density) {
if (cardHeight.isSpecified) {
with(density) {
cardHeight.roundToPx()
@@ -219,13 +218,11 @@ fun BannerCardWithTitle(
useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true,
) {
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 aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = cardHeight * aspectRationToUse
Column(
- verticalArrangement = Arrangement.spacedBy(spaceBetween),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.width(width),
) {
BannerCard(
@@ -245,13 +242,7 @@ fun BannerCardWithTitle(
imageContentScale = imageContentScale,
useSeriesForPrimary = useSeriesForPrimary,
)
- Column(
- verticalArrangement = Arrangement.spacedBy(0.dp),
- modifier =
- Modifier
- .padding(bottom = spaceBelow)
- .fillMaxWidth(),
- ) {
+ SlidingCardText(focused) {
Text(
text = title ?: "",
style = MaterialTheme.typography.bodyLarge,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt
index 54d1d2fc..34966e43 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt
@@ -5,39 +5,62 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.times
import androidx.tv.material3.Card
+import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
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.AspectRatios
+import com.github.damontecres.wholphin.ui.LocalImageUrlService
+import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.roundSeconds
-import kotlin.time.Duration
+import org.jellyfin.sdk.model.api.ImageType
/**
* Card for a [com.github.damontecres.wholphin.data.model.Chapter]
*/
@Composable
fun ChapterCard(
- name: String?,
- position: Duration,
- imageUrl: String?,
+ chapter: Chapter,
onClick: () -> Unit,
- aspectRatio: Float,
modifier: Modifier = Modifier,
cardHeight: Dp = 120.dp,
onLongClick: (() -> Unit)? = 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(
- modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
+ modifier = modifier.height(cardHeight),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
@@ -48,22 +71,36 @@ fun ChapterCard(
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
+ onSuccess = {
+ width =
+ with(density) {
+ it.painter.intrinsicSize.width
+ .toDp()
+ }
+ },
)
Box(
modifier =
Modifier
+ .width(width)
.align(Alignment.BottomStart)
- .fillMaxWidth()
.background(AppColors.TransparentBlack50),
) {
Column(modifier = Modifier.padding(4.dp)) {
- name?.let {
+ chapter.name?.let {
Text(
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 = position.roundSeconds.toString(),
+ text = positionText,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Normal,
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt
index b1e64a2a..5fa6316f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt
@@ -21,7 +21,6 @@ import com.github.damontecres.wholphin.data.model.Chapter
@Composable
fun ChapterRow(
chapters: List,
- aspectRatio: Float,
onClick: (Chapter) -> Unit,
modifier: Modifier = Modifier,
onLongClick: ((Chapter) -> Unit)? = null,
@@ -47,11 +46,8 @@ fun ChapterRow(
) {
itemsIndexed(chapters) { index, item ->
ChapterCard(
- name = item.name,
- position = item.position,
- imageUrl = item.imageUrl,
+ chapter = item,
onClick = { onClick(item) },
- aspectRatio = aspectRatio,
modifier = Modifier,
onLongClick = onLongClick?.let { { it(item) } },
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt
index 815c6f35..20cab060 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt
@@ -59,8 +59,6 @@ fun DiscoverItemCard(
width: Dp = Cards.height2x3 * AspectRatios.TALL,
) {
val focused by interactionSource.collectIsFocusedAsState()
- val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
- val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
@@ -78,7 +76,7 @@ fun DiscoverItemCard(
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(spaceBetween),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.size(width, Dp.Unspecified),
) {
Card(
@@ -130,7 +128,16 @@ fun DiscoverItemCard(
AvailableIndicator(Modifier.align(Alignment.TopEnd))
}
- else -> {}
+ SeerrAvailability.BLOCKLISTED -> {
+ // TODO handle block listed
+// BlocklistedIndicator(Modifier.align(Alignment.TopEnd))
+ }
+
+ SeerrAvailability.UNKNOWN,
+ SeerrAvailability.DELETED,
+ null,
+ -> {
+ }
}
if (showOverlay) {
val color =
@@ -171,13 +178,7 @@ fun DiscoverItemCard(
}
}
}
- Column(
- verticalArrangement = Arrangement.spacedBy(0.dp),
- modifier =
- Modifier
- .padding(bottom = spaceBelow)
- .fillMaxWidth(),
- ) {
+ SlidingCardText(focused) {
Text(
text = item?.title ?: "",
maxLines = 1,
@@ -284,6 +285,31 @@ 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
@Composable
private fun Preview() {
@@ -292,6 +318,7 @@ private fun Preview() {
PendingIndicator()
AvailableIndicator()
PartiallyAvailableIndicator()
+ BlocklistedIndicator()
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt
deleted file mode 100644
index 78080c3a..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt
+++ /dev/null
@@ -1,162 +0,0 @@
-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),
- )
- }
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt
index 1d9927fe..238adaf8 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt
@@ -17,7 +17,7 @@ fun ExtrasRow(
modifier: Modifier = Modifier,
) {
ItemRow(
- title = stringResource(R.string.extras),
+ title = stringResource(R.string.extras_title),
items = extras,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
@@ -38,7 +38,7 @@ fun ExtrasRow(
unplayedItemCount = -1,
playedPercentage = item?.playedPercentage ?: 0.0,
numberOfVersions = -1,
- aspectRatio = AspectRatios.FOUR_THREE, // TODO
+ aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE,
)
},
modifier = modifier,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt
index a32fece9..654999f4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt
@@ -30,7 +30,6 @@ import coil3.request.crossfade
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Genre
-import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import java.util.UUID
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt
index 5cf16a61..f49f2d0b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt
@@ -52,8 +52,6 @@ fun GridCard(
) {
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
@@ -71,7 +69,7 @@ fun GridCard(
}
Column(
- verticalArrangement = Arrangement.spacedBy(spaceBetween),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Card(
@@ -107,13 +105,7 @@ fun GridCard(
)
}
AnimatedVisibility(showTitle) {
- Column(
- verticalArrangement = Arrangement.spacedBy(0.dp),
- modifier =
- Modifier
- .padding(bottom = spaceBelow)
- .fillMaxWidth(),
- ) {
+ SlidingCardText(focused) {
Text(
text = item?.title ?: "",
maxLines = 1,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt
index 63f6bdd8..aaa3de97 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt
@@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService
+import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.logCoilError
import org.jellyfin.sdk.model.api.ImageType
@@ -67,14 +68,16 @@ fun ItemCardImage(
) {
val imageUrlService = LocalImageUrlService.current
val imageUrl =
- remember(item) {
- item?.let {
+ remember(item, imageType, fillWidth, fillHeight) {
+ if (item != null && (fillWidth.gt(0) || fillHeight.gt(0))) {
imageUrlService.getItemImageUrl(
item,
imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
+ } else {
+ null
}
}
ItemCardImage(
@@ -262,7 +265,7 @@ fun ItemCardImageOverlay(
}
}
- watchedPercent?.let { percent ->
+ if (watchedPercent != null && watchedPercent > 0 && watchedPercent < 100) {
Box(
modifier =
Modifier
@@ -271,7 +274,7 @@ fun ItemCardImageOverlay(
MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape)
.height(Cards.playedPercentHeight)
- .fillMaxWidth((percent / 100.0).toFloat()),
+ .fillMaxWidth((watchedPercent / 100.0).toFloat()),
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt
index 4e146134..3154d257 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt
@@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.ui.cards
-import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
@@ -26,11 +24,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.layout.layout
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
@@ -99,10 +95,6 @@ fun PersonCard(
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(
verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
modifier = modifier,
@@ -170,21 +162,7 @@ fun PersonCard(
.clip(CircleShape),
)
}
- 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(),
- ) {
+ SlidingCardText(focused) {
Text(
text = name ?: "",
maxLines = 1,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt
index 140cc2fd..637aa5cf 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt
@@ -33,7 +33,7 @@ fun PersonRow(
people: List,
onClick: (Person) -> Unit,
modifier: Modifier = Modifier,
- @StringRes title: Int = R.string.people,
+ @StringRes title: Int = R.string.people_title,
onLongClick: ((Int, Person) -> Unit)? = null,
) {
val firstFocus = remember { FocusRequester() }
@@ -84,7 +84,7 @@ fun DiscoverPersonRow(
people: List,
onClick: (DiscoverItem) -> Unit,
modifier: Modifier = Modifier,
- @StringRes title: Int = R.string.people,
+ @StringRes title: Int = R.string.people_title,
onLongClick: ((Int, DiscoverItem) -> Unit)? = null,
) {
val firstFocus = remember { FocusRequester() }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt
index 962ab727..1992cab4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
@@ -24,8 +23,8 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.isSpecified
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
@@ -38,51 +37,21 @@ import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.ImageType
/**
- * A Card for a TV Show Season, but can generically show most items
+ * A Card for a TV Show Season, but can generally show most items
*/
@Composable
fun SeasonCard(
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
+ imageHeight: Dp,
modifier: Modifier = Modifier,
- imageHeight: Dp = Dp.Unspecified,
imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false,
aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL,
) {
- 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
- }
- }
+ val imageUrl = rememberImageUrl(item, imageHeight, imageWidth)
SeasonCard(
title = item?.title,
subtitle = item?.subtitle,
@@ -104,6 +73,47 @@ 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
*/
@@ -129,8 +139,11 @@ fun SeasonCard(
) {
val focused by interactionSource.collectIsFocusedAsState()
// Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
- val spaceBetween = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
- val spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
+ val spaceBetween =
+ animateDpAsState(
+ if (focused) spaceBetweenFocused else spaceBetweenUnfocused,
+ label = "spaceBetween",
+ )
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
@@ -179,14 +192,12 @@ fun SeasonCard(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
- // Optimization: move animation reads to layout/draw phase
- .offset {
- IntOffset(0, (spaceBetween.value - 4.dp).roundToPx())
- }.layout { measurable, constraints ->
- val paddingPx = spaceBelow.value.roundToPx()
+ .layout { measurable, constraints ->
+ val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx()
+ val bottomPaddingPx = spaceBetween.value.roundToPx()
val placeable = measurable.measure(constraints)
- layout(placeable.width, placeable.height + paddingPx) {
- placeable.placeRelative(0, 0)
+ layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) {
+ placeable.placeRelative(0, topPaddingPx)
}
}.fillMaxWidth(),
) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt
new file mode 100644
index 00000000..90d99281
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt
@@ -0,0 +1,46 @@
+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,
+ )
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt
index fb89a58b..c128285d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt
@@ -32,9 +32,7 @@ import coil3.request.ImageRequest
import coil3.request.crossfade
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.PreviewTvSpec
-import com.github.damontecres.wholphin.ui.components.Genre
import com.github.damontecres.wholphin.ui.components.Studio
-import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import java.util.UUID
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt
index 56748473..fa7df8d6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt
@@ -53,8 +53,6 @@ fun ViewMoreCard(
showTitle: Boolean = true,
) {
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
@@ -79,7 +77,7 @@ fun ViewMoreCard(
size.height.takeIf { it.isSpecified } ?: (size.height * (1f / aspectRatio.ratio))
}
Column(
- verticalArrangement = Arrangement.spacedBy(spaceBetween),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Card(
@@ -95,24 +93,20 @@ fun ViewMoreCard(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
),
) {
- Box {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize(),
+ ) {
Icon(
imageVector = Icons.Default.ArrowForward,
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = "View more",
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier.fillMaxSize(.66f),
)
}
}
if (showTitle) {
- Column(
- verticalArrangement = Arrangement.spacedBy(0.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- modifier =
- Modifier
- .width(width)
- .padding(bottom = spaceBelow),
- ) {
+ SlidingCardText(focused) {
Text(
text = stringResource(R.string.view_more),
maxLines = 1,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt
index 0a92199b..ce8952a7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt
@@ -45,6 +45,8 @@ fun WatchedIconBackground(): Color =
AppThemeColors.GREEN,
AppThemeColors.ORANGE,
AppThemeColors.BOLD_BLUE,
+ AppThemeColors.RED,
+ AppThemeColors.BROWN,
-> MaterialTheme.colorScheme.border.copy(alpha = 1f)
AppThemeColors.OLED_BLACK -> MaterialTheme.colorScheme.secondaryContainer
@@ -60,6 +62,8 @@ fun WatchedIconColor(): Color =
AppThemeColors.ORANGE,
AppThemeColors.BOLD_BLUE,
AppThemeColors.OLED_BLACK,
+ AppThemeColors.RED,
+ AppThemeColors.BROWN,
-> Color.White // MaterialTheme.colorScheme.onSurface
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt
index 4e3b13ab..e00a2185 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt
@@ -7,7 +7,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
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.focus.FocusRequester
@@ -15,6 +18,9 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
+import kotlinx.coroutines.delay
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
@Composable
fun CircularProgress(modifier: Modifier = Modifier) {
@@ -58,3 +64,41 @@ 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),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
index 773bdbee..fc6d132a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -21,6 +22,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
@@ -40,6 +42,7 @@ import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.CardGrid
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.playback.scale
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
@@ -112,7 +115,7 @@ fun CollectionFolderGrid(
columns = viewOptions.columns,
spacing = viewOptions.spacing.dp,
bringIntoViewSpec =
- remember(viewOptions) {
+ remember(viewOptions, density) {
val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
if (viewOptions.showDetails) {
ScrollToTopBringIntoViewSpec(spacingPx)
@@ -204,7 +207,7 @@ val CollectionType.baseItemKinds: List
}
CollectionType.TVSHOWS -> {
- listOf(BaseItemKind.SERIES)
+ listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE)
}
CollectionType.HOMEVIDEOS -> {
@@ -219,6 +222,10 @@ val CollectionType.baseItemKinds: List
)
}
+ CollectionType.MUSICVIDEOS -> {
+ listOf(BaseItemKind.MUSIC_VIDEO)
+ }
+
CollectionType.BOXSETS -> {
listOf(BaseItemKind.BOX_SET)
}
@@ -227,7 +234,33 @@ val CollectionType.baseItemKinds: List
listOf(BaseItemKind.PLAYLIST)
}
- else -> {
+ CollectionType.PHOTOS -> {
+ 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()
}
}
@@ -256,6 +289,7 @@ data class GridClickActions(
@Composable
fun CollectionFolderHeader(
+ listIsNotEmpty: Boolean,
showHeader: Boolean,
showTitle: Boolean,
playEnabled: Boolean,
@@ -266,10 +300,14 @@ fun CollectionFolderHeader(
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List,
onClickShowViewOptions: () -> Unit,
onClickPlayAll: (Boolean) -> Unit,
+ onClickRandom: () -> Unit,
modifier: Modifier = Modifier,
currentFilter: GetItemsFilter = GetItemsFilter(),
filterOptions: List> = listOf(),
onFilterChange: (GetItemsFilter) -> Unit = {},
+ onShowFilterDropdown: ((Boolean) -> Unit)? = null,
+ filterButtonFocusRequester: FocusRequester = remember { FocusRequester() },
+ randomButtonFocusRequester: FocusRequester = remember { FocusRequester() },
) {
AnimatedVisibility(
showHeader,
@@ -277,6 +315,7 @@ fun CollectionFolderHeader(
exit = shrinkVertically(),
modifier = modifier,
) {
+ val focusRequester = remember { FocusRequester() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
@@ -286,61 +325,84 @@ fun CollectionFolderHeader(
}
val endPadding =
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
+
Row(
- horizontalArrangement = Arrangement.SpaceBetween,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(start = 16.dp, end = endPadding)
.fillMaxWidth(),
) {
- if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) {
- Row(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier,
- ) {
- if (sortOptions.isNotEmpty()) {
- SortByButton(
- sortOptions = sortOptions,
- current = sortAndDirection,
- onSortChange = onSortChange,
- modifier = Modifier,
- )
- }
- if (filterOptions.isNotEmpty()) {
- FilterByButton(
- filterOptions = filterOptions,
- current = currentFilter,
- onFilterChange = onFilterChange,
- getPossibleValues = getPossibleFilterValues,
- modifier = Modifier,
- )
- }
- ExpandableFaButton(
- title = R.string.view_options,
- iconStringRes = R.string.fa_sliders,
- onClick = onClickShowViewOptions,
- modifier = Modifier,
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier,
+ ) {
+ if (sortOptions.isNotEmpty()) {
+ SortByButton(
+ sortOptions = sortOptions,
+ current = sortAndDirection,
+ onSortChange = onSortChange,
+ modifier = Modifier.focusRequester(focusRequester),
)
}
+ if (filterOptions.isNotEmpty()) {
+ FilterByButton(
+ filterOptions = filterOptions,
+ current = currentFilter,
+ onFilterChange = onFilterChange,
+ getPossibleValues = getPossibleFilterValues,
+ modifier =
+ Modifier
+ .focusRequester(filterButtonFocusRequester)
+ .ifElse(
+ sortOptions.isEmpty(),
+ Modifier.focusRequester(focusRequester),
+ ),
+ onShow = onShowFilterDropdown,
+ )
+ }
+ ExpandableFaButton(
+ title = R.string.view_options,
+ iconStringRes = R.string.fa_sliders,
+ onClick = onClickShowViewOptions,
+ modifier =
+ Modifier.ifElse(
+ sortOptions.isEmpty() && filterOptions.isEmpty(),
+ Modifier.focusRequester(focusRequester),
+ ),
+ )
}
- if (playEnabled) {
- Row(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier,
- ) {
+
+ Spacer(Modifier.weight(1f))
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ 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(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = { onClickPlayAll.invoke(false) },
+ enabled = listIsNotEmpty,
)
ExpandableFaButton(
title = R.string.shuffle,
iconStringRes = R.string.fa_shuffle,
onClick = { onClickPlayAll.invoke(true) },
+ enabled = listIsNotEmpty,
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt
index 3c69e5ce..a048ed94 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt
@@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -38,6 +39,7 @@ import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ListItemDefaults
@@ -58,6 +60,7 @@ import com.github.damontecres.wholphin.ui.cards.WatchedIcon
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.AlphabetButtons
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.launchIO
import com.github.damontecres.wholphin.ui.rememberInt
@@ -97,13 +100,15 @@ fun CollectionFolderList(
val positionFocusRequester = remember { FocusRequester() }
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
- var showHeader by remember { mutableStateOf(true) }
+ var showHeader by rememberSaveable { mutableStateOf(true) }
fun jumpTo(newPosition: Int) {
scope.launch {
- position = newPosition
- listState.animateScrollToItem(newPosition)
- positionFocusRequester.tryRequestFocus()
+ if (newPosition >= 0) {
+ position = newPosition
+ listState.animateScrollToItem(newPosition)
+ positionFocusRequester.tryRequestFocus()
+ }
}
}
@@ -357,7 +362,7 @@ fun ListItemGeneric(
?.runTimeTicks
?.ticks
?.roundMinutes
- ?.toString() ?: "",
+ ?.let { LocalResources.current.formatDuration(it) } ?: "",
)
},
scale = scale,
@@ -419,7 +424,7 @@ fun ListItemMovie(
?.runTimeTicks
?.ticks
?.roundMinutes
- ?.toString() ?: "",
+ ?.let { LocalResources.current.formatDuration(it) } ?: "",
)
if (item?.played == true) {
WatchedIcon(padding = 0.dp)
@@ -546,7 +551,7 @@ fun ListItemAlbum(
.runTimeTicks
?.ticks
?.roundMinutes
- ?.toString() ?: "",
+ ?.let { LocalResources.current.formatDuration(it) } ?: "",
)
}
} else {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt
index ffb9cf92..52918e57 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt
@@ -44,20 +44,17 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
+import com.github.damontecres.wholphin.services.FilterOptionCache
import com.github.damontecres.wholphin.services.MediaManagementService
-import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields
-import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
-import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
-import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.SortAndDirection
-import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault
@@ -67,20 +64,22 @@ import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
-import com.github.damontecres.wholphin.ui.util.FilterUtils
import com.github.damontecres.wholphin.util.ApiRequestPager
+import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.GetArtistsHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.LoadingState
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.successValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
@@ -95,8 +94,8 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
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.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.toUUID
@@ -121,14 +120,17 @@ class CollectionFolderViewModel
private val mediaManagementService: MediaManagementService,
private val musicService: MusicService,
val streamChoiceService: StreamChoiceService,
- val mediaReportService: MediaReportService,
+ val serverReportService: ServerReportService,
+ private val filterOptionCache: FilterOptionCache,
@Assisted val itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?,
@Assisted("recursive") private val recursive: Boolean,
@Assisted private val collectionFilter: CollectionFolderFilter,
@Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean,
@Assisted defaultViewOptions: ViewOptions,
- ) : ViewModel() {
+ ) : ViewModel(),
+ ContextMenuProvider,
+ CollectionFolderViewActions {
@AssistedFactory
interface Factory {
fun create(
@@ -163,7 +165,8 @@ class CollectionFolderViewModel
val libraryDisplayInfo =
serverRepository.currentUser?.let { user ->
- libraryDisplayInfoDao.getItem(user, itemId)
+ val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId
+ libraryDisplayInfoDao.getItem(user, id)
}
_state.update {
it.copy(
@@ -240,7 +243,7 @@ class CollectionFolderViewModel
val libraryDisplayInfo =
LibraryDisplayInfo(
userId = user.rowId,
- itemId = itemId,
+ itemId = collectionFilter.libraryDisplayInfoIdOverride ?: itemId,
sort = newSort.sort,
direction = newSort.direction,
filter = newFilter,
@@ -252,9 +255,10 @@ class CollectionFolderViewModel
}
}
- fun saveViewOptions(viewOptions: ViewOptions) {
+ override fun saveViewOptions(viewOptions: ViewOptions) {
+ position = 0
_state.update { it.copy(viewOptions = viewOptions) }
- viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
+ viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
saveLibraryDisplayInfo(viewOptions = viewOptions)
if (!viewOptions.showBackdrop) {
backdropService.clearBackdrop()
@@ -262,7 +266,25 @@ class CollectionFolderViewModel
}
}
- fun onFilterChange(
+ override fun onClickRandom() {
+ viewModelScope.launchIO {
+ try {
+ val random =
+ (state.value.items.successValue as? BlockingList)?.randomBlocking()
+ Timber.d("Got random item: %s", random?.id)
+ random?.destination()?.let {
+ navigateTo(random.destination())
+ }
+ } catch (ex: CancellationException) {
+ throw ex
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error getting random")
+ showToast(context, "Error: ${ex.localizedMessage}}")
+ }
+ }
+ }
+
+ override fun onFilterChange(
newFilter: GetItemsFilter,
recursive: Boolean,
) {
@@ -277,7 +299,7 @@ class CollectionFolderViewModel
)
}
- fun onSortChange(
+ override fun onSortChange(
sortAndDirection: SortAndDirection,
recursive: Boolean,
filter: GetItemsFilter,
@@ -298,7 +320,7 @@ class CollectionFolderViewModel
recursive: Boolean,
filter: GetItemsFilter,
useSeriesForPrimary: Boolean,
- ) = viewModelScope.launch(Dispatchers.IO) {
+ ) = viewModelScope.launch(WholphinDispatchers.IO) {
_state.update {
it.copy(
items = DataLoadingState.Loading,
@@ -375,8 +397,33 @@ class CollectionFolderViewModel
)
newPager
}
+
+ GetItemsFilterOverride.ARTIST -> {
+ ApiRequestPager(
+ api,
+ createGetArtistsRequest(filter),
+ GetArtistsHandler,
+ viewModelScope,
+ useSeriesForPrimary = useSeriesForPrimary,
+ )
+ }
}
+ /**
+ * Shared with [positionOfLetter] so that the alphabet jump counts the same artists that are
+ * actually paged through
+ */
+ private fun createGetArtistsRequest(filter: GetItemsFilter): GetArtistsRequest {
+ val item = state.value.item.successValue
+ return filter.applyTo(
+ GetArtistsRequest(
+ parentId = item?.id,
+ enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
+ fields = SlimItemFields,
+ ),
+ )
+ }
+
private fun createGetItemsRequest(
sortAndDirection: SortAndDirection,
recursive: Boolean,
@@ -433,63 +480,95 @@ class CollectionFolderViewModel
return request
}
- suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List =
- FilterUtils.getFilterOptionValues(
- api,
- serverRepository.currentUser?.id,
+ override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List =
+ filterOptionCache.getFilterOptionValues(
itemId.toUUID(),
filterOption,
)
- suspend fun positionOfLetter(letter: Char): Int? =
- withContext(Dispatchers.IO) {
- val sort = state.value.sortAndDirection
+ /**
+ * The count must come from the same endpoint that [createPager] pages through, otherwise
+ * the index refers to a different result set and lands past the end of the list.
+ */
+ override suspend fun positionOfLetter(letter: Char): Int? =
+ withContext(WholphinDispatchers.IO) {
val filter = state.value.filter
- val request =
- createGetItemsRequest(
- sortAndDirection = sort,
- recursive = recursive,
- filter = filter,
- ).copy(
- enableImageTypes = null,
- fields = null,
- nameLessThan = letter.toString(),
- limit = 0,
- enableTotalRecordCount = true,
- )
- val result by GetItemsRequestHandler.execute(api, request)
- result.totalRecordCount
+ when (filter.override) {
+ GetItemsFilterOverride.ARTIST -> {
+ GetArtistsHandler.countMatching(
+ api = api,
+ request =
+ createGetArtistsRequest(filter).copy(
+ enableImageTypes = null,
+ fields = null,
+ nameLessThan = letter.toString(),
+ limit = 0,
+ enableTotalRecordCount = true,
+ enableUserData = false,
+ ),
+ )
+ }
+
+ // GetPersonsRequest has no nameLessThan or startIndex, so /Persons cannot be
+ // counted up to a letter at all
+ GetItemsFilterOverride.PERSON -> {
+ null
+ }
+
+ GetItemsFilterOverride.NONE -> {
+ GetItemsRequestHandler.countMatching(
+ api = api,
+ request =
+ createGetItemsRequest(
+ sortAndDirection = state.value.sortAndDirection,
+ recursive = recursive,
+ filter = filter,
+ ).copy(
+ enableImageTypes = null,
+ fields = null,
+ nameLessThan = letter.toString(),
+ limit = 0,
+ enableTotalRecordCount = true,
+ enableUserData = false,
+ ),
+ )
+ }
+ }
}
- fun setWatched(
+ override fun setWatched(
position: Int,
itemId: UUID,
played: Boolean,
- ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
- favoriteWatchManager.setWatched(itemId, played)
- (state.value.items as? DataLoadingState.Success)?.let {
- (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
+ ) {
+ viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
+ favoriteWatchManager.setWatched(itemId, played)
+ (state.value.items as? DataLoadingState.Success)?.let {
+ (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
+ }
}
}
- fun setFavorite(
+ override fun setFavorite(
position: Int,
itemId: UUID,
favorite: Boolean,
- ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
- favoriteWatchManager.setFavorite(itemId, favorite)
- (state.value.items as? DataLoadingState.Success)?.let {
- (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
+ ) {
+ viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
+ favoriteWatchManager.setFavorite(itemId, favorite)
+ (state.value.items as? DataLoadingState.Success)?.let {
+ (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
+ }
}
}
- fun updateBackdrop(item: BaseItem) {
+ override fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
- fun navigateTo(destination: Destination) {
+ override fun navigateTo(destination: Destination) {
release()
navigationManager.navigateTo(destination)
}
@@ -509,7 +588,7 @@ class CollectionFolderViewModel
}
}
- fun deleteItem(
+ override fun deleteItem(
index: Int,
item: BaseItem,
) {
@@ -520,7 +599,7 @@ class CollectionFolderViewModel
}
}
- fun canDelete(
+ override fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
@@ -529,6 +608,10 @@ class CollectionFolderViewModel
item: BaseItem,
index: Int,
) = addToQueue(api, musicService, item, index)
+
+ override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId)
+
+ override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true
}
data class CollectionFolderState(
@@ -637,7 +720,6 @@ fun CollectionFolderView(
useSeriesForPrimary: Boolean = true,
filterOptions: List> = DefaultFilterOptions,
focusRequesterOnEmpty: FocusRequester? = null,
- playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
viewModel: CollectionFolderViewModel =
hiltViewModel(
key = viewModelKey,
@@ -653,42 +735,96 @@ fun CollectionFolderView(
},
) {
val state by viewModel.state.collectAsState()
- var position by rememberInt(viewModel.position)
+ LifecycleResumeEffect(itemId) {
+ viewModel.onResumePage()
- var showContextMenu by remember { mutableStateOf(null) }
- var overviewDialog by remember { mutableStateOf(null) }
- var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
- val playlistState by playlistViewModel.playlistState.collectAsState()
- var showViewOptions by rememberSaveable { mutableStateOf(false) }
-
- val contextActions =
- remember {
- ContextMenuActions(
- navigateTo = viewModel::navigateTo,
- onClickWatch = { itemId, watched ->
- viewModel.setWatched(viewModel.position, itemId, watched)
- },
- onClickFavorite = { itemId, favorite ->
- viewModel.setFavorite(viewModel.position, itemId, favorite)
- },
- onClickAddPlaylist = { itemId ->
- playlistViewModel.loadPlaylists(MediaType.VIDEO)
- showPlaylistDialog.makePresent(itemId)
- },
- onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
- onDeleteItem = { viewModel.deleteItem(viewModel.position, it) },
- onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
- onChooseVersion = { _, _ ->
- // Not supported on this page
- },
- onChooseTracks = { result ->
- // Not supported on this page
- },
- onClearChosenStreams = {
- // Not supported on this page
- },
- )
+ onPauseOrDispose {
+ viewModel.release()
}
+ }
+ CollectionFolderViewContent(
+ preferences = preferences,
+ state = state,
+ savedPosition = viewModel.position,
+ itemId = itemId,
+ initialFilter = initialFilter,
+ recursive = recursive,
+ actions = actions,
+ sortOptions = sortOptions,
+ playEnabled = playEnabled,
+ defaultViewOptions = defaultViewOptions,
+ viewActions = viewModel,
+ modifier = modifier,
+ provider = viewModel,
+ showTitle = showTitle,
+ positionCallback = { columns, index ->
+ viewModel.position = index
+ positionCallback?.invoke(columns, index)
+ },
+ filterOptions = filterOptions,
+ focusRequesterOnEmpty = focusRequesterOnEmpty,
+ )
+}
+
+interface CollectionFolderViewActions {
+ fun navigateTo(destination: Destination)
+
+ fun updateBackdrop(item: BaseItem)
+
+ fun onSortChange(
+ sortAndDirection: SortAndDirection,
+ recursive: Boolean,
+ filter: GetItemsFilter,
+ )
+
+ fun onFilterChange(
+ newFilter: GetItemsFilter,
+ recursive: Boolean,
+ )
+
+ suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List
+
+ suspend fun positionOfLetter(letter: Char): Int?
+
+ fun saveViewOptions(viewOptions: ViewOptions)
+
+ /**
+ * Navigate to a random item
+ */
+ fun onClickRandom()
+}
+
+@Composable
+fun CollectionFolderViewContent(
+ preferences: UserPreferences,
+ state: CollectionFolderState,
+ savedPosition: Int,
+ itemId: String,
+ initialFilter: CollectionFolderFilter,
+ recursive: Boolean,
+ actions: GridClickActions,
+ sortOptions: List,
+ playEnabled: Boolean,
+ defaultViewOptions: ViewOptions,
+ viewActions: CollectionFolderViewActions,
+ modifier: Modifier = Modifier,
+ provider: ContextMenuProvider,
+ showTitle: Boolean = true,
+ positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
+ filterOptions: List> = DefaultFilterOptions,
+ focusRequesterOnEmpty: FocusRequester? = null,
+) {
+ var position by rememberInt(savedPosition)
+
+ // Track if leaving page due to clicking the random button so focus can be restored there
+ var clickedRandom by rememberSaveable { mutableStateOf(false) }
+
+ val contextMenu = rememberContextMenu(preferences, provider)
+ var showViewOptions by rememberSaveable { mutableStateOf(false) }
+ var filterDropdownShowing by remember { mutableStateOf(false) }
+ val headerRowFocusRequester = remember { FocusRequester() }
+ val filterButtonFocusRequester = remember { FocusRequester() }
+ val randomButtonFocusRequester = remember { FocusRequester() }
val gridActions =
remember(actions) {
@@ -702,18 +838,7 @@ fun CollectionFolderView(
if (actions.onLongClickItem != null) {
actions.onLongClickItem.invoke(index, item)
} else {
- showContextMenu =
- ContextMenu.ForBaseItem(
- fromLongClick = true,
- item = item,
- chosenStreams = null,
- showGoTo = true,
- showStreamChoices = false,
- canDelete = viewModel.canDelete(item, preferences.appPreferences),
- canRemoveContinueWatching = false,
- canRemoveNextUp = false,
- actions = contextActions,
- )
+ contextMenu.showContextMenu(index, item)
}
},
onClickPlayAll =
@@ -739,7 +864,7 @@ fun CollectionFolderView(
filter = state.filter,
)
}
- viewModel.navigateTo(destination)
+ viewActions.navigateTo(destination)
}
Unit
},
@@ -758,7 +883,7 @@ fun CollectionFolderView(
} else {
Destination.Playback(item)
}
- viewModel.navigateTo(destination)
+ viewActions.navigateTo(destination)
},
)
}
@@ -782,14 +907,6 @@ fun CollectionFolderView(
?: item?.data?.collectionType?.name
?: stringResource(R.string.collection)
Column(modifier = Modifier.fillMaxSize()) {
- val headerRowFocusRequester = remember { FocusRequester() }
- LifecycleResumeEffect(itemId) {
- viewModel.onResumePage()
-
- onPauseOrDispose {
- viewModel.release()
- }
- }
var showHeader by rememberSaveable { mutableStateOf(true) }
val gridFocusRequester = remember { FocusRequester() }
val pager = remember(state.items) { state.items.successValue }
@@ -797,30 +914,38 @@ fun CollectionFolderView(
val focusedItem = pager?.getOrNull(position)
if (state.viewOptions.showBackdrop) {
LaunchedEffect(focusedItem) {
- focusedItem?.let(viewModel::updateBackdrop)
+ focusedItem?.let(viewActions::updateBackdrop)
}
}
CollectionFolderHeader(
+ listIsNotEmpty = state.items.successValue?.isNotEmpty() == true,
showHeader = showHeader || state.items !is DataLoadingState.Success,
showTitle = showTitle,
- playEnabled = playEnabled && state.items.successValue?.isNotEmpty() == true,
+ playEnabled = playEnabled,
title = title,
sortAndDirection = state.sortAndDirection,
onSortChange = {
- viewModel.onSortChange(it, recursive, state.filter)
+ viewActions.onSortChange(it, recursive, state.filter)
},
sortOptions = sortOptions,
currentFilter = state.filter,
onFilterChange = {
- viewModel.onFilterChange(it, recursive)
+ viewActions.onFilterChange(it, recursive)
},
getPossibleFilterValues = {
- viewModel.getFilterOptionValues(it)
+ viewActions.getFilterOptionValues(it)
},
filterOptions = filterOptions,
onClickPlayAll = gridActions.onClickPlayAll!!,
onClickShowViewOptions = { showViewOptions = true },
+ onClickRandom = {
+ clickedRandom = true
+ viewActions.onClickRandom()
+ },
modifier = Modifier.focusRequester(headerRowFocusRequester),
+ onShowFilterDropdown = { filterDropdownShowing = it },
+ filterButtonFocusRequester = filterButtonFocusRequester,
+ randomButtonFocusRequester = randomButtonFocusRequester,
)
when (val pager = state.items) {
@@ -839,16 +964,23 @@ fun CollectionFolderView(
is DataLoadingState.Success> -> {
LaunchedEffect(Unit) {
- if (pager.data.isNotEmpty()) {
- gridFocusRequester.tryRequestFocus()
- }
+ val focusRequester =
+ when {
+ contextMenu.isShowing -> null
+ filterDropdownShowing -> filterButtonFocusRequester
+ clickedRandom -> randomButtonFocusRequester
+ pager.data.isNotEmpty() -> gridFocusRequester
+ else -> focusRequesterOnEmpty ?: headerRowFocusRequester
+ }
+ focusRequester?.tryRequestFocus()
+ clickedRandom = false
}
Box(Modifier.fillMaxSize()) {
if (state.viewOptions.type == ViewOptionsType.GRID) {
CollectionFolderGrid(
preferences = preferences,
collectionType = item?.data?.collectionType,
- initialPosition = viewModel.position,
+ initialPosition = savedPosition,
items = pager.data,
sortAndDirection = state.sortAndDirection,
modifier = Modifier.fillMaxSize(),
@@ -856,12 +988,11 @@ fun CollectionFolderView(
onClickItem = gridActions.onClickItem,
onLongClickItem = gridActions.onLongClickItem!!,
positionCallback = { columns, pos ->
- viewModel.position = pos
showHeader = pos < columns
position = pos
positionCallback?.invoke(columns, pos)
},
- letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
+ letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
viewOptions = state.viewOptions,
onClickPlay = gridActions.onClickPlayRemoteButton!!,
focusedItem = focusedItem,
@@ -870,7 +1001,7 @@ fun CollectionFolderView(
CollectionFolderList(
preferences = preferences,
collectionType = item?.data?.collectionType,
- initialPosition = viewModel.position,
+ initialPosition = savedPosition,
items = pager.data,
sortAndDirection = state.sortAndDirection,
modifier = Modifier.fillMaxSize(),
@@ -878,12 +1009,11 @@ fun CollectionFolderView(
onClickItem = gridActions.onClickItem,
onLongClickItem = gridActions.onLongClickItem!!,
positionCallback = { columns, pos ->
- viewModel.position = pos
showHeader = pos < columns
position = pos
positionCallback?.invoke(columns, pos)
},
- letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
+ letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
viewOptions = state.viewOptions,
onClickPlay = gridActions.onClickPlayRemoteButton!!,
focusedItem = focusedItem,
@@ -912,50 +1042,16 @@ fun CollectionFolderView(
}
}
}
- overviewDialog?.let { info ->
- ItemDetailsDialog(
- info = info,
- showFilePath =
- viewModel.serverRepository.currentUserDto
- ?.policy
- ?.isAdministrator == true,
- onDismissRequest = { overviewDialog = null },
- )
- }
- showContextMenu?.let { contextMenu ->
- ContextMenuDialog(
- onDismissRequest = { showContextMenu = null },
- getMediaSource = null,
- contextMenu = contextMenu,
- preferredSubtitleLanguage = null,
- )
- }
- showPlaylistDialog.compose { itemId ->
- PlaylistDialog(
- title = stringResource(R.string.add_to_playlist),
- state = playlistState,
- onDismissRequest = { showPlaylistDialog.makeAbsent() },
- onClick = {
- playlistViewModel.addToPlaylist(it.id, itemId)
- showPlaylistDialog.makeAbsent()
- },
- createEnabled = true,
- onCreatePlaylist = {
- playlistViewModel.createPlaylistAndAddItem(it, itemId)
- showPlaylistDialog.makeAbsent()
- },
- elevation = 3.dp,
- )
- }
+ contextMenu.Compose()
AnimatedVisibility(showViewOptions) {
ViewOptionsDialog(
viewOptions = state.viewOptions,
defaultViewOptions = defaultViewOptions,
onDismissRequest = {
showViewOptions = false
- viewModel.saveViewOptions(state.viewOptions)
+ viewActions.saveViewOptions(state.viewOptions)
},
- onViewOptionsChange = viewModel::saveViewOptions,
+ onViewOptionsChange = viewActions::saveViewOptions,
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt
index c41c27f0..2f39f238 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt
@@ -28,6 +28,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.supportedPlayableTypes
+import com.github.damontecres.wholphin.util.supportedShufflableTypes
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
@@ -40,6 +41,7 @@ sealed interface ContextMenu {
data class ForBaseItem(
val fromLongClick: Boolean,
val item: BaseItem,
+ val index: Int = UNSPECIFIED_INDEX,
val chosenStreams: ChosenStreams?,
val showGoTo: Boolean,
val showStreamChoices: Boolean,
@@ -47,6 +49,7 @@ sealed interface ContextMenu {
val canRemoveContinueWatching: Boolean,
val canRemoveNextUp: Boolean,
val actions: ContextMenuActions,
+ val showRemoveFromPlaylist: Boolean = false,
) : ContextMenu
data class ForPerson(
@@ -62,6 +65,7 @@ sealed interface ContextMenu {
val canDelete: Boolean,
val canRemoveFromQueue: Boolean,
val actions: MusicContextActions,
+ val showRemoveFromPlaylist: Boolean = false,
) : ContextMenu
data class ForQueue(
@@ -70,6 +74,10 @@ sealed interface ContextMenu {
val index: Int,
val actions: QueueContextActions,
) : ContextMenu
+
+ companion object {
+ const val UNSPECIFIED_INDEX = -12
+ }
}
data class ContextMenuActions(
@@ -86,6 +94,7 @@ data class ContextMenuActions(
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
val onClickRemoveFromNextUp: (BaseItem) -> Unit = {},
val onClickAddToQueue: (BaseItem) -> Unit = {},
+ val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> },
)
data class PersonContextActions(
@@ -108,6 +117,7 @@ data class MusicContextActions(
val onClickGoToArtist: (UUID) -> Unit = {
navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
},
+ val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> },
)
data class QueueContextActions(
@@ -199,11 +209,7 @@ fun ContextMenu(
seriesId = item.data.seriesId,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
- showGoTo = contextMenu.showGoTo,
- showStreamChoices = contextMenu.showStreamChoices,
- canDelete = contextMenu.canDelete,
- canRemoveContinueWatching = contextMenu.canRemoveContinueWatching,
- canRemoveNextUp = contextMenu.canRemoveNextUp,
+ contextMenu = contextMenu,
actions = actions,
onChooseVersion = {
chooseVersion =
@@ -274,6 +280,7 @@ fun ContextMenu(
onDismissRequest = { chooseVersion = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
+ elevation = 3.dp,
)
}
}
@@ -285,6 +292,7 @@ fun ContextMenu(
actions.onDeleteItem.invoke(item)
onDismissRequest.invoke()
},
+ elevation = 3.dp,
)
}
if (showPlayWithDialog) {
@@ -316,17 +324,13 @@ fun ContextMenu(
private fun buildContextMenuItems(
resources: Resources,
+ contextMenu: ContextMenu.ForBaseItem,
item: BaseItem,
seriesId: UUID?,
sourceId: UUID?,
watched: Boolean,
favorite: Boolean,
canClearChosenStreams: Boolean,
- showGoTo: Boolean,
- showStreamChoices: Boolean,
- canDelete: Boolean,
- canRemoveContinueWatching: Boolean,
- canRemoveNextUp: Boolean,
actions: ContextMenuActions,
onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit,
@@ -336,7 +340,8 @@ private fun buildContextMenuItems(
onClickPlayWith: () -> Unit,
): List =
buildList {
- if (showGoTo) {
+ // Songs should not show Go to
+ if (contextMenu.showGoTo && item.type != BaseItemKind.AUDIO) {
add(
DialogItem(
resources.getString(R.string.go_to),
@@ -353,7 +358,6 @@ private fun buildContextMenuItems(
DialogItem(
resources.getString(R.string.resume),
Icons.Default.PlayArrow,
- iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.navigateTo(
@@ -383,7 +387,6 @@ private fun buildContextMenuItems(
DialogItem(
resources.getString(R.string.play),
Icons.Default.PlayArrow,
- iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.navigateTo(
@@ -396,7 +399,23 @@ private fun buildContextMenuItems(
)
}
}
- if (showStreamChoices) {
+ if (item.type in supportedShufflableTypes) {
+ add(
+ DialogItem(
+ resources.getString(R.string.shuffle),
+ R.string.fa_shuffle,
+ dismissOnClick = true,
+ ) {
+ actions.navigateTo(
+ Destination.PlaybackList(
+ itemId = item.id,
+ shuffle = true,
+ ),
+ )
+ },
+ )
+ }
+ if (contextMenu.showStreamChoices) {
item.data.mediaSources?.letNotEmpty { sources ->
val source =
sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } }
@@ -449,7 +468,7 @@ private fun buildContextMenuItems(
}
}
}
- if (item.type == BaseItemKind.MUSIC_ALBUM) {
+ if (item.type == BaseItemKind.MUSIC_ALBUM || item.type == BaseItemKind.AUDIO) {
add(
DialogItem(
resources.getString(R.string.add_to_queue),
@@ -460,6 +479,17 @@ private fun buildContextMenuItems(
},
)
}
+ if (contextMenu.showRemoveFromPlaylist) {
+ add(
+ DialogItem(
+ text = R.string.remove_from_playlist,
+ iconStringRes = R.string.fa_circle_xmark,
+ dismissOnClick = true,
+ ) {
+ actions.onRemoveFromPlaylist.invoke(contextMenu.index, item.id)
+ },
+ )
+ }
add(
DialogItem(
text = R.string.add_to_playlist,
@@ -469,7 +499,7 @@ private fun buildContextMenuItems(
actions.onClickAddPlaylist.invoke(item.id)
},
)
- if (canDelete) {
+ if (contextMenu.canDelete) {
add(
DialogItem(
resources.getString(R.string.delete),
@@ -481,7 +511,7 @@ private fun buildContextMenuItems(
},
)
}
- if (canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) {
+ if (contextMenu.canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) {
add(
DialogItem(
text = R.string.remove_continue_watching,
@@ -492,7 +522,7 @@ private fun buildContextMenuItems(
},
)
}
- if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) {
+ if (contextMenu.canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) {
add(
DialogItem(
text = R.string.remove_next_up,
@@ -584,7 +614,7 @@ private fun buildContextMenuItems(
},
)
}
- if (showStreamChoices && canClearChosenStreams) {
+ if (contextMenu.showStreamChoices && canClearChosenStreams) {
add(
DialogItem(
resources.getString(R.string.clear_track_choices),
@@ -732,7 +762,6 @@ fun buildContextForMusic(
DialogItem(
resources.getString(R.string.play),
Icons.Default.PlayArrow,
- iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.onClickPlay(index, item)
@@ -742,7 +771,6 @@ fun buildContextForMusic(
DialogItem(
resources.getString(R.string.play_next),
Icons.Default.PlayArrow,
- iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.onClickPlayNext(index, item)
@@ -769,6 +797,17 @@ fun buildContextForMusic(
},
)
}
+ if (music.showRemoveFromPlaylist) {
+ add(
+ DialogItem(
+ text = R.string.remove_from_playlist,
+ iconStringRes = R.string.fa_circle_xmark,
+ dismissOnClick = true,
+ ) {
+ actions.onRemoveFromPlaylist.invoke(index, item.id)
+ },
+ )
+ }
add(
DialogItem(
text = R.string.add_to_playlist,
@@ -865,7 +904,6 @@ fun buildContextForMusicQueue(
DialogItem(
resources.getString(R.string.play),
Icons.Default.PlayArrow,
- iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.onClickPlay(index, item)
@@ -875,7 +913,6 @@ fun buildContextForMusicQueue(
DialogItem(
resources.getString(R.string.play_next),
Icons.Default.PlayArrow,
- iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.onClickPlayNext(index, item)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt
new file mode 100644
index 00000000..2261e0ac
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt
@@ -0,0 +1,166 @@
+package com.github.damontecres.wholphin.ui.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
+import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
+import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
+import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
+import com.github.damontecres.wholphin.ui.nav.Destination
+import java.util.UUID
+
+/**
+ * Simplifies showing a basic context menu
+ */
+@Composable
+fun rememberContextMenu(
+ preferences: UserPreferences,
+ provider: ContextMenuProvider,
+): ContextMenuUtils = remember(preferences, provider) { ContextMenuUtils(preferences, provider) }
+
+class ContextMenuUtils(
+ private val preferences: UserPreferences,
+ private val provider: ContextMenuProvider,
+) {
+ private var showContextMenu by mutableStateOf?>(null)
+
+ val isShowing: Boolean get() = showContextMenu != null
+
+ /**
+ * Show the context menu, typically from a long click
+ */
+ fun showContextMenu(
+ position: Int,
+ item: BaseItem,
+ ) {
+ showContextMenu = Pair(position, item)
+ }
+
+ /**
+ * Composes the context menu when needed
+ */
+ @Composable
+ fun Compose(playlistViewModel: AddPlaylistViewModel = hiltViewModel()) {
+ var overviewDialog by remember { mutableStateOf(null) }
+ var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
+ val playlistState by playlistViewModel.playlistState.collectAsState()
+
+ overviewDialog?.let { info ->
+ ItemDetailsDialog(
+ info = info,
+ showFilePath = provider.isAdministrator(),
+ onDismissRequest = { overviewDialog = null },
+ )
+ }
+ showContextMenu?.let { (position, item) ->
+ val contextActions =
+ remember {
+ ContextMenuActions(
+ navigateTo = provider::navigateTo,
+ onClickWatch = { itemId, watched ->
+ provider.setWatched(position, itemId, watched)
+ },
+ onClickFavorite = { itemId, favorite ->
+ provider.setFavorite(position, itemId, favorite)
+ },
+ onClickAddPlaylist = { itemId ->
+ playlistViewModel.loadPlaylists()
+ showPlaylistDialog.makePresent(itemId)
+ },
+ onSendMediaInfo = provider::sendReportFor,
+ onDeleteItem = { provider.deleteItem(position, it) },
+ onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
+ onChooseVersion = { _, _ ->
+ // Not supported on this page
+ },
+ onChooseTracks = { result ->
+ // Not supported on this page
+ },
+ onClearChosenStreams = {
+ // Not supported on this page
+ },
+ onClickAddToQueue = playlistViewModel::addToQueue,
+ )
+ }
+ val contextMenu =
+ remember {
+ ContextMenu.ForBaseItem(
+ fromLongClick = true,
+ item = item,
+ chosenStreams = null,
+ showGoTo = true,
+ showStreamChoices = false,
+ canDelete = provider.canDelete(item, preferences.appPreferences),
+ canRemoveContinueWatching = false,
+ canRemoveNextUp = false,
+ actions = contextActions,
+ )
+ }
+ ContextMenuDialog(
+ onDismissRequest = { showContextMenu = null },
+ getMediaSource = null,
+ contextMenu = contextMenu,
+ preferredSubtitleLanguage = null,
+ )
+ }
+ showPlaylistDialog.compose { itemId ->
+ PlaylistDialog(
+ title = stringResource(R.string.add_to_playlist),
+ state = playlistState,
+ onDismissRequest = { showPlaylistDialog.makeAbsent() },
+ onClick = {
+ playlistViewModel.addToPlaylist(it.id, itemId)
+ showPlaylistDialog.makeAbsent()
+ },
+ createEnabled = true,
+ onCreatePlaylist = {
+ playlistViewModel.createPlaylistAndAddItem(it, itemId)
+ showPlaylistDialog.makeAbsent()
+ },
+ onSearch = playlistViewModel::loadPlaylists,
+ elevation = 3.dp,
+ )
+ }
+ }
+}
+
+interface ContextMenuProvider {
+ fun isAdministrator(): Boolean
+
+ fun navigateTo(destination: Destination)
+
+ fun canDelete(
+ item: BaseItem,
+ appPreferences: AppPreferences,
+ ): Boolean
+
+ fun deleteItem(
+ index: Int,
+ item: BaseItem,
+ )
+
+ fun setWatched(
+ position: Int,
+ itemId: UUID,
+ played: Boolean,
+ )
+
+ fun setFavorite(
+ position: Int,
+ itemId: UUID,
+ favorite: Boolean,
+ )
+
+ fun sendReportFor(itemId: UUID)
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
index 6bd3dfaa..d7bd9951 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
@@ -63,19 +63,19 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
+import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling
import com.github.damontecres.wholphin.ui.FontAwesome
-import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream
-import com.github.damontecres.wholphin.ui.playback.isDown
-import com.github.damontecres.wholphin.ui.playback.isUp
-import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
+import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
@@ -85,7 +85,7 @@ import java.util.UUID
data class DialogParams(
val fromLongClick: Boolean,
val title: String,
- val items: List,
+ val items: List,
)
sealed interface DialogItemEntry
@@ -319,30 +319,13 @@ fun DialogPopupContent(
modifier =
Modifier
.focusRequester(focusRequesters[index])
- .ifElse(
+ .lazyListWrapScrolling(
+ listState,
+ focused,
index == 0,
- Modifier.onKeyEvent {
- if (focused && isUp(it) && it.type == KeyEventType.KeyDown) {
- scope.launch {
- listState.animateScrollToItem(dialogItems.lastIndex)
- focusRequesters[dialogItems.lastIndex].tryRequestFocus()
- }
- return@onKeyEvent true
- }
- false
- },
- ).ifElse(
index == dialogItems.lastIndex,
- Modifier.onKeyEvent {
- if (focused && isDown(it) && it.type == KeyEventType.KeyDown) {
- scope.launch {
- listState.animateScrollToItem(0)
- focusRequesters[0].tryRequestFocus()
- }
- return@onKeyEvent true
- }
- false
- },
+ focusRequesters[0],
+ focusRequesters[dialogItems.lastIndex],
),
)
}
@@ -630,7 +613,24 @@ fun chooseVersionParams(
SelectedLeadingContent(uuid != null && uuid == chosenSourceId)
},
supportingContent = {
- videoStream?.displayTitle?.let { Text(text = it) }
+ val text =
+ remember {
+ buildList {
+ videoStream?.displayTitle?.let(::add)
+ source.bitrate?.let(::formatBitrate)?.let(::add)
+ }.joinToString(", ")
+ }
+ Text(text)
+ },
+ trailingContent = {
+ val runtime =
+ remember {
+ source.runTimeTicks
+ ?.ticks
+ ?.roundMinutes
+ .toString()
+ }
+ Text(runtime)
},
onClick = { onClick.invoke(index) },
)
@@ -655,8 +655,9 @@ fun chooseStream(
type: MediaStreamType,
preferredSubtitleLanguage: String?,
onClick: (Int) -> Unit,
-): DialogParams =
- DialogParams(
+): DialogParams {
+ val filteredStreams = streams.filter { it.type == type }
+ return DialogParams(
fromLongClick = false,
title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))),
items =
@@ -689,10 +690,12 @@ fun chooseStream(
onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) },
),
)
+ if (filteredStreams.isNotEmpty()) {
+ add(DialogItemDivider)
+ }
}
addAll(
- streams
- .filter { it.type == type }
+ filteredStreams
.let {
if (type == MediaStreamType.SUBTITLE && preferredSubtitleLanguage.isNotNullOrBlank()) {
it.sortedByDescending { it.language != null && it.language == preferredSubtitleLanguage }
@@ -708,7 +711,9 @@ fun chooseStream(
},
headlineContent = {
Text(
- text = simpleStream.streamTitle ?: simpleStream.displayTitle,
+ text =
+ simpleStream.streamTitle
+ ?: simpleStream.displayTitle,
)
},
supportingContent = {
@@ -720,3 +725,4 @@ fun chooseStream(
)
},
)
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt
index 59bb456f..b1995b3d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt
@@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.ui.components
-import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@@ -10,30 +9,25 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
+import com.github.damontecres.wholphin.services.ServerReportService
+import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
-import dagger.hilt.android.qualifiers.ApplicationContext
-import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.model.ClientInfo
-import org.jellyfin.sdk.model.DeviceInfo
import javax.inject.Inject
@HiltViewModel
class ErrorViewModel
@Inject
constructor(
- @param:ApplicationContext private val context: Context,
- private val api: ApiClient,
- private val clientInfo: ClientInfo,
- private val deviceInfo: DeviceInfo,
+ private val serverReportService: ServerReportService,
) : ViewModel() {
fun sendLogs() {
- sendAppLogs(context, api, clientInfo, deviceInfo)
+ viewModelScope.launchIO { serverReportService.sendAppLogs() }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt
index e881bb98..af64ef15 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt
@@ -37,8 +37,20 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.api.seerr.model.TvDetails
+import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus
import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter
import com.github.damontecres.wholphin.data.filter.DecadeFilter
+import com.github.damontecres.wholphin.data.filter.DiscoverFilter
+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.FilterValueOption
import com.github.damontecres.wholphin.data.filter.FilterVideoType
@@ -71,6 +83,7 @@ fun FilterByButton(
onFilterChange: (GetItemsFilter) -> Unit,
getPossibleValues: suspend (ItemFilterBy<*>) -> List,
modifier: Modifier = Modifier,
+ onShow: ((Boolean) -> Unit)? = null,
) {
var dropDown by remember { mutableStateOf(false) }
var nestedDropDown by remember { mutableStateOf?>(null) }
@@ -79,7 +92,10 @@ fun FilterByButton(
Box(modifier = modifier) {
ExpandableFilterButton(
filterCount = filterCount,
- onClick = { dropDown = true },
+ onClick = {
+ onShow?.invoke(true)
+ dropDown = true
+ },
modifier = Modifier,
)
@@ -87,6 +103,7 @@ fun FilterByButton(
expanded = dropDown,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
onDismissRequest = {
+ onShow?.invoke(false)
dropDown = false
nestedDropDown = null
},
@@ -422,6 +439,286 @@ fun ExpandableFilterButton(
}
}
+// TODO this all duplicated code from FilterByButton
+@Composable
+fun DiscoverFilterByButton(
+ filterOptions: List>,
+ current: DiscoverFilter,
+ onFilterChange: (DiscoverFilter) -> Unit,
+ getPossibleValues: suspend (DiscoverFilterBy<*>) -> List,
+ modifier: Modifier = Modifier,
+ onShow: ((Boolean) -> Unit)? = null,
+) {
+ var dropDown by remember { mutableStateOf(false) }
+ var nestedDropDown by remember { mutableStateOf?>(null) }
+ val filterCount = remember(current, filterOptions) { current.countFilters(filterOptions) }
+
+ Box(modifier = modifier) {
+ ExpandableFilterButton(
+ filterCount = filterCount,
+ onClick = {
+ onShow?.invoke(true)
+ dropDown = true
+ },
+ modifier = Modifier,
+ )
+
+ DropdownMenu(
+ expanded = dropDown,
+ containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
+ onDismissRequest = {
+ onShow?.invoke(false)
+ dropDown = false
+ nestedDropDown = null
+ },
+ ) {
+ if (filterCount > 0) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val focused by interactionSource.collectIsFocusedAsState()
+ TvDropdownMenuItem(
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = null,
+ tint = Color.Red.copy(alpha = .8f),
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(R.string.remove),
+ color = if (focused) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.error,
+ )
+ },
+ onClick = {
+ onFilterChange.invoke(current.delete(filterOptions))
+ dropDown = false
+ },
+ interactionSource = interactionSource,
+ modifier = Modifier,
+ )
+ HorizontalDivider()
+ }
+ filterOptions
+ .forEachIndexed { index, filterOption ->
+ val focusRequester = remember { FocusRequester() }
+ if (index == 0) {
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ }
+ val currentValue = remember(current) { filterOption.get(current) }
+ TvDropdownMenuItem(
+ leadingIcon = {
+ if (currentValue != null) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = "Filter active",
+ )
+ }
+ },
+ text = {
+ Text(
+ text = stringResource(filterOption.stringRes),
+ )
+ },
+ onClick = {
+ nestedDropDown = filterOption
+ },
+ modifier = Modifier.focusRequester(focusRequester),
+ )
+ }
+ }
+
+ DropdownMenu(
+ expanded = nestedDropDown != null,
+ containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp),
+ offset = DpOffset(80.dp, 16.dp),
+ onDismissRequest = {
+ nestedDropDown = null
+ },
+ ) {
+ nestedDropDown?.let { filterOption ->
+ filterOption as DiscoverFilterBy
+ val currentValue = remember(current) { filterOption.get(current) }
+
+ var possibleValues by remember { mutableStateOf?>(null) }
+ LaunchedEffect(Unit) {
+ possibleValues = getPossibleValues.invoke(filterOption)
+ }
+
+ if (currentValue != null) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val focused by interactionSource.collectIsFocusedAsState()
+ TvDropdownMenuItem(
+ elevation = 5.dp,
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = null,
+ tint = Color.Red.copy(alpha = .8f),
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(R.string.remove),
+ color = if (focused) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.error,
+ )
+ },
+ onClick = {
+ onFilterChange.invoke(filterOption.set(null, current))
+ nestedDropDown = null
+ },
+ interactionSource = interactionSource,
+ modifier = Modifier,
+ )
+ HorizontalDivider()
+ }
+
+ if (possibleValues == null) {
+ CircularProgress(Modifier.size(48.dp))
+ } else if (possibleValues?.isEmpty() == true) {
+ Text(
+ text = stringResource(R.string.no_results),
+ )
+ } else {
+ possibleValues.orEmpty().forEachIndexed { index, value ->
+ val focusRequester = remember { FocusRequester() }
+ if (index == 0) {
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ }
+
+ val isSelected =
+ remember(currentValue) {
+ when (filterOption) {
+ DiscoverMovieGenreFilter,
+ DiscoverTvGenreFilter,
+ DiscoverMovieStudiosFilter,
+ DiscoverTvStudiosFilter,
+ -> {
+ (currentValue as? List)
+ .orEmpty()
+ .contains(value.value)
+ }
+
+ DiscoverTvStatusFilter -> {
+ (currentValue as? List)
+ .orEmpty()
+ .contains(value.value)
+ }
+
+ DiscoverMovieContentRatingFilter,
+ DiscoverTvContentRatingFilter,
+ -> {
+ (currentValue as? List)
+ .orEmpty()
+ .contains(value.value)
+ }
+
+ DiscoverRatingFilter -> {
+ (currentValue as? Int) == value.value
+ }
+ }
+ }
+ val interactionSource = remember { MutableInteractionSource() }
+ val focused by interactionSource.collectIsFocusedAsState()
+ TvDropdownMenuItem(
+ elevation = 8.dp,
+ interactionSource = interactionSource,
+ leadingIcon = {
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = "Filter active",
+ )
+ }
+ },
+ text = {
+ if (filterOption == DiscoverRatingFilter) {
+ SimpleStarRating(
+ "${value.name}+",
+ starColor = if (focused) EmptyStarColor else FilledStarColor,
+ )
+ } else {
+ Text(
+ text = value.name,
+ )
+ }
+ },
+ onClick = {
+ val newFilter =
+ when (filterOption) {
+ DiscoverMovieGenreFilter,
+ DiscoverTvGenreFilter,
+ DiscoverMovieStudiosFilter,
+ DiscoverTvStudiosFilter,
+ -> {
+ val list = (currentValue as? List).orEmpty()
+ val newValue =
+ list
+ .toMutableList()
+ .apply {
+ if (isSelected) {
+ remove(value.value!!)
+ } else {
+ add(value.value!! as Int)
+ }
+ }.takeIf { it.isNotEmpty() }
+ filterOption.set(newValue, current)
+ }
+
+ DiscoverTvStatusFilter -> {
+ val list =
+ (currentValue as? List).orEmpty()
+ val newValue =
+ list
+ .toMutableList()
+ .apply {
+ if (isSelected) {
+ remove(value.value!!)
+ } else {
+ add(value.value!! as TvShowStatus)
+ }
+ }.takeIf { it.isNotEmpty() }
+ filterOption.set(newValue, current)
+ }
+
+ DiscoverMovieContentRatingFilter,
+ DiscoverTvContentRatingFilter,
+ -> {
+ val list = (currentValue as? List).orEmpty()
+ val newValue =
+ list
+ .toMutableList()
+ .apply {
+ if (isSelected) {
+ remove(value.value!!)
+ } else {
+ add(value.value!! as String)
+ }
+ }.takeIf { it.isNotEmpty() }
+ filterOption.set(newValue, current)
+ }
+
+ DiscoverRatingFilter -> {
+ filterOption.set(
+ value.value as? Int,
+ current,
+ )
+ }
+ }
+
+ onFilterChange.invoke(newFilter)
+ if (!filterOption.supportMultiple) {
+ nestedDropDown = null
+ }
+ },
+ modifier = Modifier.focusRequester(focusRequester),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
@PreviewTvSpec
@Composable
private fun ExpandableFilterButtonPreview() {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt
index ac19cf30..ecc643fa 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt
@@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.alphabetSortName
import com.github.damontecres.wholphin.data.model.createGenreDestination
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
@@ -33,13 +34,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.mayakapps.kache.InMemoryKache
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
@@ -51,6 +52,7 @@ import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
@@ -103,7 +105,12 @@ class GenreViewModel
.execute(api, request)
.content.items
.map {
- Genre(it.id, it.name ?: "", null)
+ Genre(
+ id = it.id,
+ name = it.name ?: "",
+ imageUrl = null,
+ sortName = it.alphabetSortName,
+ )
}
_state.update {
it.copy(
@@ -141,17 +148,18 @@ class GenreViewModel
}
suspend fun positionOfLetter(letter: Char): Int =
- withContext(Dispatchers.IO) {
- val request =
- GetGenresRequest(
- parentId = itemId,
- nameLessThan = letter.toString(),
- limit = 0,
- enableTotalRecordCount = true,
- includeItemTypes = includeItemTypes,
- )
- val result by GetGenresRequestHandler.execute(api, request)
- return@withContext result.totalRecordCount
+ withContext(WholphinDispatchers.IO) {
+ GetGenresRequestHandler.countMatching(
+ api = api,
+ request =
+ GetGenresRequest(
+ parentId = itemId,
+ nameLessThan = letter.toString(),
+ limit = 0,
+ enableTotalRecordCount = true,
+ includeItemTypes = includeItemTypes,
+ ),
+ )
}
}
@@ -196,7 +204,7 @@ suspend fun getGenreImageMap(
val semaphore = Semaphore(4)
genres
.map { genreId ->
- scope.async(Dispatchers.IO) {
+ scope.async(WholphinDispatchers.IO) {
semaphore.withPermit {
val item =
GetItemsRequestHandler
@@ -242,10 +250,10 @@ data class Genre(
val id: UUID,
val name: String,
val imageUrl: String?,
+ override val sortName: String = name,
) : CardGridItem {
override val gridId: String get() = id.toString()
override val playable: Boolean = false
- override val sortName: String get() = name
}
/**
@@ -255,6 +263,7 @@ data class Genre(
fun GenreCardGrid(
itemId: UUID,
includeItemTypes: List?,
+ collectionType: CollectionType,
modifier: Modifier = Modifier,
initialPosition: Int = 0,
viewModel: GenreViewModel =
@@ -267,7 +276,7 @@ fun GenreCardGrid(
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val cardWidthPx =
- remember {
+ remember(density) {
with(density) {
// Grid has 16dp padding on either side & 16dp spacing between 4 cards
// This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster
@@ -308,6 +317,7 @@ fun GenreCardGrid(
parentId = itemId,
parentName = item.title,
includeItemTypes = includeItemTypes,
+ collectionType = collectionType,
),
)
},
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt
index d74707e5..f86868bc 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt
@@ -25,7 +25,7 @@ fun HiddenFocusBox(
modifier =
Modifier
.fillMaxWidth()
- .height(1.dp)
+ .height(0.dp)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) onFocus.invoke()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt
index 65329026..3609cdfa 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt
@@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.components
+import android.content.Context
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@@ -7,49 +8,73 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.FavoriteWatchManager
+import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.ServerReportService
+import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.cards.GridCard
+import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.detail.CardGrid
+import com.github.damontecres.wholphin.ui.equalsNotNull
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.rememberInt
+import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
+import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RequestHandler
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
+import java.util.UUID
@HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class)
class ItemGridViewModel
@AssistedInject
constructor(
+ @param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val navigationManager: NavigationManager,
+ private val favoriteWatchManager: FavoriteWatchManager,
+ private val mediaManagementService: MediaManagementService,
+ val serverRepository: ServerRepository,
+ val serverReportService: ServerReportService,
@Assisted private val destination: Destination.ItemGrid<*>,
- ) : ViewModel() {
- private val _state = MutableStateFlow(ItemGridState())
- val state: StateFlow = _state
-
+ ) : ViewModel(),
+ ContextMenuProvider {
@AssistedFactory
interface Factory {
fun create(destination: Destination.ItemGrid<*>): ItemGridViewModel
}
+ private val _state = MutableStateFlow(ItemGridState())
+ val state: StateFlow = _state
+
init {
viewModelScope.launchIO {
try {
@@ -77,9 +102,78 @@ class ItemGridViewModel
}
}
- fun navigateTo(destination: Destination) {
+ override fun navigateTo(destination: Destination) {
navigationManager.navigateTo(destination)
}
+
+ override fun canDelete(
+ item: BaseItem,
+ appPreferences: AppPreferences,
+ ): Boolean = mediaManagementService.canDelete(item, appPreferences)
+
+ override fun deleteItem(
+ index: Int,
+ item: BaseItem,
+ ) {
+ deleteItem(context, mediaManagementService, item) {
+ viewModelScope.launchDefault {
+ refreshAfterDelete(index, item)
+ }
+ }
+ }
+
+ private suspend fun refreshAfterDelete(
+ position: Int,
+ deletedItem: BaseItem,
+ ) {
+ try {
+ val pager =
+ ((state.value.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
+ position.let {
+ Timber.v("Item deleted: position=%s", it)
+ val item = pager?.get(it)
+ // Exact item deleted (eg a movie) or deleted item was within the series
+ if (item?.id == deletedItem.id ||
+ equalsNotNull(item?.data?.id, deletedItem.data.seriesId)
+ ) {
+ pager?.refreshPagesAfter(position)
+ }
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error refreshing after deleted item %s", deletedItem.id)
+ showToast(context, "Error refreshing after item deleted")
+ }
+ }
+
+ override fun setWatched(
+ position: Int,
+ itemId: UUID,
+ played: Boolean,
+ ) {
+ viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
+ favoriteWatchManager.setWatched(itemId, played)
+ (state.value.items as? DataLoadingState.Success)?.let {
+ (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
+ }
+ }
+ }
+
+ override fun setFavorite(
+ position: Int,
+ itemId: UUID,
+ favorite: Boolean,
+ ) {
+ viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
+ favoriteWatchManager.setFavorite(itemId, favorite)
+ (state.value.items as? DataLoadingState.Success)?.let {
+ (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
+ }
+ }
+ }
+
+ override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true
+
+ override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId)
}
data class ItemGridState(
@@ -91,13 +185,17 @@ data class ItemGridState(
*/
@Composable
fun ItemGrid(
+ preferences: UserPreferences,
destination: Destination.ItemGrid<*>,
modifier: Modifier = Modifier,
+ playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
viewModel: ItemGridViewModel =
hiltViewModel(
creationCallback = { it.create(destination) },
),
) {
+ val contextMenu = rememberContextMenu(preferences, viewModel)
+
val state by viewModel.state.collectAsState()
when (val st = state.items) {
is DataLoadingState.Error -> {
@@ -111,20 +209,25 @@ fun ItemGrid(
}
is DataLoadingState.Success> -> {
+ var position by rememberInt(destination.initialPosition)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
+
Column(modifier = modifier) {
GridTitle(destination.title.getString())
CardGrid(
pager = st.data,
onClickItem = { index: Int, item: BaseItem ->
- // TODO handle more types
- viewModel.navigateTo(Destination.Playback(item.id, 0))
+ position = index
+ viewModel.navigateTo(item.destination(index))
+ },
+ onLongClickItem = { index: Int, item: BaseItem ->
+ position = index
+ contextMenu.showContextMenu(index, item)
},
- onLongClickItem = { index: Int, item: BaseItem -> },
onClickPlay = { _, item -> viewModel.navigateTo(Destination.Playback(item)) },
letterPosition = { c: Char -> 0 },
gridFocusRequester = focusRequester,
@@ -148,4 +251,5 @@ fun ItemGrid(
}
}
}
+ contextMenu.Compose()
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt
index ca4c11b8..4137759d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt
@@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
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
@@ -42,6 +43,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -55,12 +57,15 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec
+import com.github.damontecres.wholphin.ui.data.ChooseVersionParams
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
+import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
+import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -81,10 +86,23 @@ fun ExpandablePlayButtons(
moreOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit,
onConfirmDelete: () -> Unit,
+ chooseVersionParams: ChooseVersionParams?,
buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier,
) {
+ val resources = LocalResources.current
val firstFocus = remember { FocusRequester() }
+ val restartInteractionSource = remember { MutableInteractionSource() }
+ val restartButtonFocused by restartInteractionSource.collectIsFocusedAsState()
+ LaunchedEffect(resumePosition) {
+ // Reset focus if the restart button was focuses and it is going to disappear
+ if (restartButtonFocused && resumePosition == Duration.ZERO) {
+ firstFocus.tryRequestFocus()
+ }
+ }
+
+ var chooseVersion by remember { mutableStateOf(null) }
+
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
@@ -93,19 +111,19 @@ fun ExpandablePlayButtons(
.focusGroup()
.focusRestorer(firstFocus),
) {
+ item("play") {
+ ExpandablePlayButton(
+ title = if (resumePosition > Duration.ZERO) R.string.resume else R.string.play,
+ resume = resumePosition,
+ icon = Icons.Default.PlayArrow,
+ onClick = playOnClick,
+ modifier =
+ Modifier
+ .onFocusChanged(buttonOnFocusChanged)
+ .focusRequester(firstFocus),
+ )
+ }
if (resumePosition > Duration.ZERO) {
- item("play") {
- ExpandablePlayButton(
- title = R.string.resume,
- resume = resumePosition,
- icon = Icons.Default.PlayArrow,
- onClick = playOnClick,
- modifier =
- Modifier
- .onFocusChanged(buttonOnFocusChanged)
- .focusRequester(firstFocus),
- )
- }
item("restart") {
ExpandablePlayButton(
title = R.string.restart,
@@ -114,19 +132,31 @@ fun ExpandablePlayButtons(
onClick = playOnClick,
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
mirrorIcon = true,
+ interactionSource = restartInteractionSource,
)
}
- } else {
- item("play") {
- ExpandablePlayButton(
- title = R.string.play,
- resume = Duration.ZERO,
- icon = Icons.Default.PlayArrow,
- onClick = playOnClick,
- modifier =
- Modifier
- .onFocusChanged(buttonOnFocusChanged)
- .focusRequester(firstFocus),
+ }
+
+ if (chooseVersionParams != null && chooseVersionParams.mediaSources.size > 1) {
+ item {
+ ExpandableFaButton(
+ title = R.string.version,
+ iconStringRes = R.string.fa_file_video,
+ onClick = {
+ chooseVersion =
+ chooseVersionParams(
+ resources,
+ chooseVersionParams.mediaSources.orEmpty(),
+ chooseVersionParams.chosenStreams
+ ?.source
+ ?.id
+ ?.toUUIDOrNull(),
+ ) { idx ->
+ val source = chooseVersionParams.mediaSources[idx]
+ chooseVersionParams.onChooseVersion.invoke(source)
+ }
+ },
+ modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
)
}
}
@@ -184,6 +214,16 @@ fun ExpandablePlayButtons(
)
}
}
+ chooseVersion?.let { params ->
+ DialogPopup(
+ showDialog = true,
+ title = params.title,
+ dialogItems = params.items,
+ onDismissRequest = { chooseVersion = null },
+ dismissOnClick = true,
+ waitToLoad = params.fromLongClick,
+ )
+ }
}
val MinButtonSize = 40.dp
@@ -359,7 +399,7 @@ fun TrailerButton(
} else if (trailers.size == 1) {
R.string.play_trailer
} else {
- R.string.trailers
+ R.string.trailers_title
},
iconStringRes = R.string.fa_film,
enabled = trailers.isNotEmpty(),
@@ -453,6 +493,7 @@ private fun ExpandablePlayButtonsPreview() {
trailerOnClick = {},
canDelete = true,
onConfirmDelete = {},
+ chooseVersionParams = null,
modifier = Modifier,
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt
index 2c30f337..6db89c75 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt
@@ -26,9 +26,10 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.preferences.DisplayToggle
import com.github.damontecres.wholphin.ui.dot
-import com.github.damontecres.wholphin.ui.getTimeFormatter
+import com.github.damontecres.wholphin.ui.formatTime
import com.github.damontecres.wholphin.ui.util.LocalClock
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
+import org.jellyfin.sdk.model.DateTime
import kotlin.time.Duration
@Composable
@@ -37,6 +38,7 @@ fun QuickDetails(
timeRemaining: Duration?,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
+ endsAt: DateTime? = null,
) {
val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles
val inlineContentMap = rememberQuickDetailsContentMap(textStyle)
@@ -53,7 +55,11 @@ fun QuickDetails(
QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap)
}
}
- timeRemaining?.let { TimeRemaining(it, textStyle = textStyle) }
+ if (timeRemaining != null) {
+ TimeRemaining(timeRemaining, textStyle = textStyle)
+ } else if (endsAt != null) {
+ EndsAt(endsAt, textStyle = textStyle)
+ }
}
}
@@ -133,11 +139,25 @@ fun TimeRemaining(
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
) {
- val resources = LocalResources.current
val now by LocalClock.current.now
+ EndsAt(
+ endsAt = now.plusSeconds(remaining.inWholeSeconds),
+ modifier = modifier,
+ textStyle = textStyle,
+ )
+}
+
+@Composable
+fun EndsAt(
+ endsAt: DateTime,
+ modifier: Modifier = Modifier,
+ textStyle: TextStyle = MaterialTheme.typography.titleSmall,
+) {
+ val resources = LocalResources.current
+ val context = LocalContext.current
val remainingStr =
- remember(remaining, now, resources) {
- val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds))
+ remember(endsAt, resources, context) {
+ val endTimeStr = formatTime(context, endsAt)
buildAnnotatedString {
dot()
append(resources.getString(R.string.ends_at, endTimeStr))
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt
index 88e86197..ca4ae7ff 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt
@@ -22,9 +22,9 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
-import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.services.SuggestionService
import com.github.damontecres.wholphin.services.SuggestionsResource
import com.github.damontecres.wholphin.services.UserPreferencesService
@@ -48,13 +48,13 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.RequestHandler
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
@@ -62,7 +62,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
-import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.util.UUID
@@ -81,7 +80,7 @@ class RecommendedViewModel
private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService,
private val suggestionService: SuggestionService,
- val mediaReportService: MediaReportService,
+ val serverReportService: ServerReportService,
@Assisted private val parentId: UUID,
@Assisted private val suggestionsType: BaseItemKind,
@Assisted private val recommendedRows: List>,
@@ -120,10 +119,12 @@ class RecommendedViewModel
viewModelScope.launchIO {
val result =
try {
+ val items = execute(row, limit)
HomeRowLoadingState.Success(
title,
- execute(row, limit),
+ items,
viewOptions,
+ showViewMore = items.size >= limit,
)
} catch (ex: Exception) {
Timber.e(ex, "Exception fetching %s", title)
@@ -171,7 +172,11 @@ class RecommendedViewModel
}
private fun fetchSuggestions() {
- viewModelScope.launch(Dispatchers.IO) {
+ viewModelScope.launch(WholphinDispatchers.IO) {
+ val limit =
+ userPreferencesService
+ .getCurrent()
+ .appPreferences.homePagePreferences.maxItemsPerRow
val title = ResStringProvider(R.string.suggestions)
try {
suggestionService
@@ -188,6 +193,7 @@ class RecommendedViewModel
title,
resource.items,
viewOptions,
+ showViewMore = resource.items.size >= limit,
)
}
@@ -196,6 +202,7 @@ class RecommendedViewModel
title,
emptyList(),
viewOptions,
+ showViewMore = false,
)
}
}
@@ -380,10 +387,10 @@ fun RecommendedContent(
viewModel.setFavorite(position, itemId, favorite)
},
onClickAddPlaylist = { itemId ->
- playlistViewModel.loadPlaylists(MediaType.VIDEO)
+ playlistViewModel.loadPlaylists()
showPlaylistDialog.makePresent(itemId)
},
- onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
+ onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor,
onDeleteItem = { viewModel.deleteItem(position, it) },
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
onChooseVersion = { _, _ ->
@@ -477,12 +484,8 @@ fun RecommendedContent(
playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent()
},
+ onSearch = playlistViewModel::loadPlaylists,
elevation = 3.dp,
)
}
}
-
-data class RowColumnItem(
- val position: RowColumn,
- val item: BaseItem,
-)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt
index f87f5c98..9021e213 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt
@@ -146,6 +146,8 @@ fun sliderActiveColor(focused: Boolean): Color {
AppThemeColors.BLUE,
AppThemeColors.GREEN,
AppThemeColors.ORANGE,
+ AppThemeColors.RED,
+ AppThemeColors.BROWN,
-> {
MaterialTheme.colorScheme.border
}
@@ -188,6 +190,8 @@ fun sliderInactiveColor(focused: Boolean): Color {
AppThemeColors.GREEN,
AppThemeColors.ORANGE,
AppThemeColors.BOLD_BLUE,
+ AppThemeColors.RED,
+ AppThemeColors.BROWN,
-> {
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .66f)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt
index 53511a3a..3e2555db 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt
@@ -14,6 +14,8 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.filter.DiscoverSort
+import com.github.damontecres.wholphin.data.filter.DiscoverSortAndDirection
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.flip
@@ -115,3 +117,94 @@ fun SortAndDirection.toAnnotatedString() =
append(" ")
append(stringResource(getStringRes(sort)))
}
+
+// TODO code duplicated from SortByButton
+@Composable
+fun DiscoverSortByButton(
+ sortOptions: List,
+ current: DiscoverSortAndDirection,
+ onSortChange: (DiscoverSortAndDirection) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val currentSort = current.sort
+ val currentDirection = current.direction
+ var sortByDropDown by remember { mutableStateOf(false) }
+
+ Box(modifier = modifier) {
+ TextButton(
+ onClick = { sortByDropDown = true },
+ onLongClick = {
+ onSortChange.invoke(current.flip())
+ },
+ ) {
+ Text(
+ text = current.toAnnotatedString(),
+ )
+ }
+
+ DropdownMenu(
+ expanded = sortByDropDown,
+ onDismissRequest = { sortByDropDown = false },
+ ) {
+ sortOptions
+// .sortedBy { it.name }
+ .forEach { sortOption ->
+ TvDropdownMenuItem(
+ leadingIcon = {
+ if (sortOption == currentSort) {
+ if (currentDirection == SortOrder.ASCENDING) {
+ Text(
+ text = stringResource(R.string.fa_caret_up),
+ fontFamily = FontAwesome,
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.fa_caret_down),
+ fontFamily = FontAwesome,
+ )
+ }
+ }
+ },
+ text = {
+ Text(
+ text = stringResource(sortOption.stringRes),
+ )
+ },
+ onClick = {
+ sortByDropDown = false
+ val newDirection =
+ if (currentSort == sortOption) {
+ currentDirection.flip()
+ } else {
+ currentDirection
+ }
+ onSortChange.invoke(
+ DiscoverSortAndDirection(
+ sortOption,
+ newDirection,
+ ),
+ )
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun DiscoverSortAndDirection.toAnnotatedString() =
+ buildAnnotatedString {
+ withStyle(SpanStyle(fontFamily = FontAwesome)) {
+ append(
+ stringResource(
+ if (direction == SortOrder.ASCENDING) {
+ R.string.fa_caret_up
+ } else {
+ R.string.fa_caret_down
+ },
+ ),
+ )
+ }
+ append(" ")
+ append(stringResource(sort.stringRes))
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt
index cf3e7ed9..9ff0e2c0 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt
@@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.alphabetSortName
import com.github.damontecres.wholphin.data.model.createStudioDestination
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
@@ -32,11 +33,11 @@ import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@@ -97,7 +98,12 @@ class StudioViewModel
imageType = ImageType.THUMB,
fillWidth = cardWidthPx,
)
- Studio(it.id, it.name ?: "", imageUrl)
+ Studio(
+ id = it.id,
+ name = it.name ?: "",
+ imageUrl = imageUrl,
+ sortName = it.alphabetSortName,
+ )
}
_state.update {
it.copy(
@@ -113,18 +119,19 @@ class StudioViewModel
}
suspend fun positionOfLetter(letter: Char): Int =
- withContext(Dispatchers.IO) {
- val request =
- GetStudiosRequest(
- userId = serverRepository.currentUser?.id,
- parentId = itemId,
- nameLessThan = letter.toString(),
- limit = 0,
- enableTotalRecordCount = true,
- includeItemTypes = includeItemTypes,
- )
- val result by GetStudiosRequestHandler.execute(api, request)
- return@withContext result.totalRecordCount
+ withContext(WholphinDispatchers.IO) {
+ GetStudiosRequestHandler.countMatching(
+ api = api,
+ request =
+ GetStudiosRequest(
+ userId = serverRepository.currentUser?.id,
+ parentId = itemId,
+ nameLessThan = letter.toString(),
+ limit = 0,
+ enableTotalRecordCount = true,
+ includeItemTypes = includeItemTypes,
+ ),
+ )
}
}
@@ -133,10 +140,10 @@ data class Studio(
val id: UUID,
val name: String,
val imageUrl: String?,
+ override val sortName: String = name,
) : CardGridItem {
override val gridId: String get() = id.toString()
override val playable: Boolean = false
- override val sortName: String get() = name
}
data class StudioGridState(
@@ -160,7 +167,7 @@ fun StudioCardGrid(
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val cardWidthPx =
- remember {
+ remember(density) {
with(density) {
// Grid has 16dp padding on either side & 16dp spacing between 4 cards
// This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt
index f54fda5b..f2f60ad8 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt
@@ -14,8 +14,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -29,7 +31,6 @@ import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
@@ -42,26 +43,27 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.ui.util.StringStringProvider
import timber.log.Timber
+import java.util.SortedMap
@Composable
fun TabRow(
selectedTabIndex: Int,
- tabs: List,
- focusRequesters: List,
+ tabs: List,
onClick: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val state = rememberLazyListState()
LaunchedEffect(selectedTabIndex) {
- if (selectedTabIndex >= 0) {
+ if (selectedTabIndex in tabs.indices) {
state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt())
}
}
var rowHasFocus by remember { mutableStateOf(false) }
val currentSelectedTabIndex by rememberUpdatedState(selectedTabIndex)
- val currentFocusRequesters by rememberUpdatedState(focusRequesters)
+// val currentFocusRequesters by rememberUpdatedState(focusRequesters)
val currentOnClick by rememberUpdatedState(onClick)
LazyRow(
@@ -76,21 +78,21 @@ fun TabRow(
// If entering from left or right, use last or first tab
// Otherwise use the selected tab
val index = currentSelectedTabIndex
- val requesters = currentFocusRequesters
+// val requesters = currentFocusRequesters
Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$index")
- val focusRequester =
+ val tab =
if (requestedFocusDirection == FocusDirection.Left) {
- requesters.lastOrNull()
+ tabs.lastOrNull()
} else if (requestedFocusDirection == FocusDirection.Right) {
- requesters.firstOrNull()
+ tabs.firstOrNull()
} else {
- requesters.getOrNull(index)
+ tabs.getOrNull(index)
}
- (focusRequester ?: FocusRequester.Default).tryRequestFocus()
+ (tab?.tabFocusRequester ?: FocusRequester.Default).tryRequestFocus()
}
},
) {
- itemsIndexed(tabs) { index, tabTitle ->
+ itemsIndexed(tabs) { index, tab ->
val interactionSource = remember { MutableInteractionSource() }
val onTabClick =
remember(index) {
@@ -99,17 +101,90 @@ fun TabRow(
}
}
Tab(
- title = tabTitle,
+ title = tab.title.getString(),
selected = index == selectedTabIndex,
rowActive = rowHasFocus,
interactionSource = interactionSource,
onClick = onTabClick,
- modifier = Modifier.focusRequester(focusRequesters.getOrElse(index) { FocusRequester() }),
+ modifier = Modifier.focusRequester(tab.tabFocusRequester),
)
}
}
}
+@Composable
+fun KeyedTabRow(
+ selectedTabKey: T,
+ tabs: SortedMap,
+ onClick: (T) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val state = rememberLazyListState()
+ LaunchedEffect(selectedTabKey) {
+ tabs[selectedTabKey]?.bringIntoViewRequester?.bringIntoView()
+ }
+ var rowHasFocus by remember { mutableStateOf(false) }
+ val currentSelectedTabKey by rememberUpdatedState(selectedTabKey)
+ val currentOnClick by rememberUpdatedState(onClick)
+
+ val tabKeyList = remember(tabs) { tabs.keys.filterNotNull().toList() }
+
+ LazyRow(
+ state = state,
+ modifier =
+ modifier
+ .onFocusChanged {
+ rowHasFocus = it.hasFocus
+ }.focusGroup()
+ .focusProperties {
+ onEnter = {
+ // If entering from left or right, use last or first tab
+ // Otherwise use the selected tab
+ val currentKey = currentSelectedTabKey
+// val requesters = currentFocusRequesters
+ Timber.v(
+ "onEnter requestedFocusDirection=%s, currentSelectedTabKey=%s",
+ requestedFocusDirection,
+ currentSelectedTabKey,
+ )
+ val key =
+ if (requestedFocusDirection == FocusDirection.Left) {
+ tabs.lastKey()
+ } else if (requestedFocusDirection == FocusDirection.Right) {
+ tabs.firstKey()
+ } else {
+ currentKey
+ }
+ val tab = tabs[key]
+ (tab?.tabFocusRequester ?: FocusRequester.Default).tryRequestFocus()
+ }
+ },
+ ) {
+ items(tabKeyList, key = { it }) { tabKey ->
+ tabs[tabKey]?.let { tab ->
+ val interactionSource = remember { MutableInteractionSource() }
+ val onTabClick =
+ remember(tabKey) {
+ {
+ currentOnClick(tabKey)
+ }
+ }
+ Tab(
+ title = tab.title.getString(),
+ selected = tabKey == selectedTabKey,
+ rowActive = rowHasFocus,
+ interactionSource = interactionSource,
+ onClick = onTabClick,
+ modifier =
+ Modifier
+ .focusRequester(tab.tabFocusRequester)
+ .bringIntoViewRequester(tab.bringIntoViewRequester),
+ )
+ }
+ }
+ }
+}
+
@Composable
fun Tab(
title: String,
@@ -195,9 +270,11 @@ private fun TabRowPreview() {
WholphinTheme {
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
TabRow(
- selectedTabIndex = 1,
- tabs = listOf("Tab 1", "Tab 2", "Tab 3"),
- focusRequesters = listOf(),
+ selectedTabIndex = -1,
+ tabs =
+ remember {
+ listOf("Tab 1", "Tab 2", "Tab 3").map { TabDetails(StringStringProvider(it)) }
+ },
onClick = {},
)
Tab(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt
new file mode 100644
index 00000000..0749c068
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt
@@ -0,0 +1,241 @@
+package com.github.damontecres.wholphin.ui.components
+
+import androidx.annotation.StringRes
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.services.BackdropService
+import com.github.damontecres.wholphin.services.RememberedTabService
+import com.github.damontecres.wholphin.services.UserPreferencesService
+import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.util.ResStringProvider
+import com.github.damontecres.wholphin.ui.util.StringProvider
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import timber.log.Timber
+import java.util.SortedMap
+
+@HiltViewModel(assistedFactory = TabViewModel.Factory::class)
+class TabViewModel
+ @AssistedInject
+ constructor(
+ val userPreferencesService: UserPreferencesService,
+ private val rememberedTabService: RememberedTabService,
+ private val backdropService: BackdropService,
+ @param:Assisted private val itemId: String,
+ @param:Assisted private val tabCount: Int,
+ ) : ViewModel() {
+ @AssistedFactory
+ interface Factory {
+ fun create(
+ itemId: String,
+ tabCount: Int,
+ ): TabViewModel
+ }
+
+ private val _state = MutableStateFlow(UNSET)
+ val state: StateFlow = _state
+
+ val isShowClock =
+ userPreferencesService.flow.map { it.appPreferences.interfacePreferences.showClock }
+
+ init {
+ viewModelScope.launchIO {
+ val startingTab =
+ if (shouldRememberTabs()) {
+ Timber.v("Getting remembered tab for %s", itemId)
+ try {
+ rememberedTabService
+ .getRememberedTab(itemId)
+ ?.coerceIn(0..,
+ modifier: Modifier = Modifier,
+ showTabs: Boolean = true,
+ viewModel: TabViewModel =
+ hiltViewModel(
+ key = "$itemId-${tabs.size}",
+ creationCallback = { it.create(itemId, tabs.size) },
+ ),
+ tabContent: @Composable (Int, TabDetails) -> Unit,
+) {
+ val selectedTabIndex by viewModel.state.collectAsState()
+ val isShowClock by viewModel.isShowClock.collectAsState(true)
+ TabbedPage(
+ selectedTabIndex = selectedTabIndex,
+ tabs = tabs,
+ updateSelectedTabIndex = viewModel::updateSelectedTabIndex,
+ isShowClock = isShowClock,
+ modifier = modifier,
+ showTabs = showTabs,
+ tabContent = tabContent,
+ )
+}
+
+@Composable
+fun TabbedPage(
+ selectedTabIndex: Int,
+ tabs: List,
+ updateSelectedTabIndex: (Int) -> Unit,
+ isShowClock: Boolean,
+ modifier: Modifier = Modifier,
+ showTabs: Boolean = true,
+ tabContent: @Composable (Int, TabDetails) -> Unit,
+) {
+ val endPadding =
+ remember(isShowClock) {
+ if (isShowClock) {
+ 184.dp
+ } else {
+ 0.dp
+ }
+ }
+ Column(
+ modifier = modifier,
+ ) {
+ AnimatedVisibility(
+ showTabs,
+ enter = expandVertically(),
+ exit = shrinkVertically(),
+ ) {
+ TabRow(
+ selectedTabIndex = selectedTabIndex,
+ modifier =
+ Modifier
+ .padding(top = 16.dp, bottom = 16.dp, end = endPadding),
+ tabs = tabs,
+ onClick = updateSelectedTabIndex,
+ )
+ }
+ selectedTabIndex.let { tabIndex ->
+ if (tabIndex in tabs.indices) {
+ tabContent.invoke(tabIndex, tabs[tabIndex])
+ } else {
+ DelayedLoadingPage(focusEnabled = false)
+ }
+ }
+ }
+}
+
+@Composable
+fun KeyedTabbedPage(
+ selectedTabKey: T,
+ tabs: SortedMap,
+ updateSelectedTabKey: (T) -> Unit,
+ isShowClock: Boolean,
+ modifier: Modifier = Modifier,
+ showTabs: Boolean = true,
+ tabContent: @Composable (T, TabDetails) -> Unit,
+) {
+ val endPadding =
+ remember(isShowClock) {
+ if (isShowClock) {
+ 184.dp
+ } else {
+ 0.dp
+ }
+ }
+ Column(
+ modifier = modifier,
+ ) {
+ AnimatedVisibility(
+ showTabs,
+ enter = expandVertically(),
+ exit = shrinkVertically(),
+ ) {
+ KeyedTabRow(
+ selectedTabKey = selectedTabKey,
+ modifier =
+ Modifier
+ .padding(top = 16.dp, bottom = 16.dp, end = endPadding),
+ tabs = tabs,
+ onClick = updateSelectedTabKey,
+ )
+ }
+ AnimatedContent(
+ targetState = selectedTabKey,
+ transitionSpec = {
+ (fadeIn(animationSpec = tween(300, delayMillis = 100)))
+ .togetherWith(fadeOut(animationSpec = tween(200)))
+ },
+ ) { tabKey ->
+ val tab = tabs[tabKey]
+ if (tab != null) {
+ tabContent.invoke(tabKey, tab)
+ } else {
+ DelayedLoadingPage(focusEnabled = false)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt
index ff001d0b..99288b9a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt
@@ -21,6 +21,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.LocalImageUrlService
+import com.github.damontecres.wholphin.ui.logCoilError
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
@@ -40,6 +41,10 @@ fun TitleOrLogo(
model = logoImageUrl,
contentDescription = title,
contentScale = ContentScale.Fit,
+ onError = {
+ logCoilError(logoImageUrl, it.result)
+ imageError = true
+ },
modifier =
Modifier
.height(HeaderUtils.logoHeight)
@@ -113,6 +118,10 @@ fun SimpleTitleOrLogo(
model = logoImageUrl,
contentDescription = item?.title,
contentScale = ContentScale.Fit,
+ onError = {
+ logCoilError(logoImageUrl, it.result)
+ imageError = true
+ },
modifier = Modifier.fillMaxSize(),
)
} else {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
index 4f4d7588..a1237acd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
@@ -4,8 +4,11 @@ import android.view.Gravity
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -19,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -37,6 +41,7 @@ import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.serialization.Serializable
+import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
/**
@@ -74,45 +79,52 @@ fun ViewOptionsDialog(
ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS
}
}
- LazyColumn(
- state = columnState,
- contentPadding = PaddingValues(16.dp),
+ Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.width(256.dp)
.heightIn(max = 380.dp)
- .focusRequester(focusRequester)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
shape = RoundedCornerShape(8.dp),
),
) {
- stickyHeader {
- Text(
- text = stringResource(R.string.view_options),
- style = MaterialTheme.typography.titleMedium,
- )
- }
- items(options, key = { it.title }) { pref ->
- pref as AppPreference
- val interactionSource = remember { MutableInteractionSource() }
- val value = pref.getter.invoke(viewOptions)
- ComposablePreference(
- preference = pref,
- value = value,
- onNavigate = {},
- onValueChange = { newValue ->
- onViewOptionsChange.invoke(pref.setter(viewOptions, newValue))
- },
- interactionSource = interactionSource,
- modifier = Modifier,
- onClickPreference = { pref ->
- if (pref == ViewOptions.ViewOptionsReset) {
- onViewOptionsChange.invoke(defaultViewOptions)
- }
- },
- )
+ Text(
+ text = stringResource(R.string.view_options),
+ style = MaterialTheme.typography.titleMedium,
+ textAlign = TextAlign.Center,
+ modifier =
+ Modifier
+ .padding(vertical = 8.dp)
+ .fillMaxWidth(),
+ )
+ LazyColumn(
+ state = columnState,
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ modifier = Modifier.focusRequester(focusRequester),
+ ) {
+ items(options, key = { it.title }) { pref ->
+ pref as AppPreference
+ val interactionSource = remember { MutableInteractionSource() }
+ val value = pref.getter.invoke(viewOptions)
+ ComposablePreference(
+ preference = pref,
+ value = value,
+ onNavigate = {},
+ onValueChange = { newValue ->
+ onViewOptionsChange.invoke(pref.setter(viewOptions, newValue))
+ },
+ interactionSource = interactionSource,
+ modifier = Modifier,
+ onClickPreference = { pref ->
+ if (pref == ViewOptions.ViewOptionsReset) {
+ onViewOptionsChange.invoke(defaultViewOptions)
+ }
+ },
+ )
+ }
}
}
}
@@ -302,3 +314,27 @@ enum class ViewOptionsType {
LIST,
DENSE_LIST,
}
+
+val BaseItemKind.defaultViewOptions: ViewOptions
+ get() =
+ when (this) {
+ BaseItemKind.AUDIO,
+ BaseItemKind.MUSIC_ALBUM,
+ BaseItemKind.MUSIC_ARTIST,
+ BaseItemKind.PLAYLIST,
+ -> ViewOptionsSquare
+
+ BaseItemKind.EPISODE,
+ BaseItemKind.VIDEO,
+ BaseItemKind.MUSIC_VIDEO,
+ BaseItemKind.PHOTO,
+ BaseItemKind.PHOTO_ALBUM,
+ BaseItemKind.TV_CHANNEL,
+ BaseItemKind.LIVE_TV_CHANNEL,
+ BaseItemKind.PROGRAM,
+ BaseItemKind.TV_PROGRAM,
+ BaseItemKind.LIVE_TV_PROGRAM,
+ -> ViewOptionsWide
+
+ else -> ViewOptionsPoster
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt
index d2d6fa32..57874b0c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt
@@ -17,10 +17,10 @@ import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.core.content.ContextCompat
import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.android.asCoroutineDispatcher
@@ -175,7 +175,7 @@ class VoiceInputManager
private fun provideMainDispatcher(): CoroutineDispatcher =
try {
- Dispatchers.Main.immediate
+ WholphinDispatchers.Main.immediate
} catch (_: IllegalStateException) {
// Fallback for unit tests where Main dispatcher is not installed
handler.asCoroutineDispatcher()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt
index c2b8c94a..4dc1dd16 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt
@@ -5,15 +5,19 @@ import android.widget.Toast
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
+import com.github.damontecres.wholphin.ui.detail.music.addToQueue
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
-import org.jellyfin.sdk.model.api.MediaType
+import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@@ -27,17 +31,19 @@ class AddPlaylistViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
private val playlistCreator: PlaylistCreator,
+ private val musicService: MusicService,
) : ViewModel() {
val playlistState = MutableStateFlow(PlaylistLoadingState.Pending)
- fun loadPlaylists(mediaType: MediaType?) {
+ fun loadPlaylists(query: String = "") {
viewModelScope.launchIO {
this@AddPlaylistViewModel.playlistState.value = PlaylistLoadingState.Loading
try {
- val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope)
+ val playlists = playlistCreator.getServerPlaylists(query, null)
this@AddPlaylistViewModel.playlistState.value =
- PlaylistLoadingState.Success(playlists)
+ PlaylistLoadingState.Success(playlists, query)
} catch (ex: Exception) {
playlistState.value = PlaylistLoadingState.Error(ex)
}
@@ -72,4 +78,10 @@ class AddPlaylistViewModel
}
}
}
+
+ fun addToQueue(item: BaseItem) {
+ viewModelScope.launchDefault {
+ addToQueue(api, musicService, item, -1)
+ }
+ }
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt
new file mode 100644
index 00000000..397a2126
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt
@@ -0,0 +1,13 @@
+package com.github.damontecres.wholphin.ui.data
+
+import com.github.damontecres.wholphin.data.ChosenStreams
+import org.jellyfin.sdk.model.api.MediaSourceInfo
+
+/**
+ * Necessary details & action for choosing a version of some content
+ */
+data class ChooseVersionParams(
+ val chosenStreams: ChosenStreams?,
+ val mediaSources: List,
+ val onChooseVersion: (MediaSourceInfo) -> Unit,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
index f0591868..08bd0c55 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
@@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.ui.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.formatDate
+import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec
@@ -153,7 +154,7 @@ fun ItemDetailsDialog(
)
}
source.runTimeTicks?.let {
- add(runtimeLabel to it.ticks.toString())
+ add(runtimeLabel to resources.formatDuration(it.ticks))
}
},
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt
index 30edee62..36b4e5c7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt
@@ -1,8 +1,12 @@
package com.github.damontecres.wholphin.ui.data
import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import com.github.damontecres.wholphin.R
import kotlinx.serialization.Serializable
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
@@ -44,8 +48,9 @@ val SeriesSortOptions =
ItemSortBy.PREMIERE_DATE,
ItemSortBy.DATE_CREATED,
ItemSortBy.DATE_LAST_CONTENT_ADDED,
- ItemSortBy.DATE_PLAYED,
+ ItemSortBy.SERIES_DATE_PLAYED,
ItemSortBy.COMMUNITY_RATING,
+ ItemSortBy.CRITIC_RATING,
ItemSortBy.OFFICIAL_RATING,
ItemSortBy.RANDOM,
)
@@ -53,19 +58,27 @@ val SeriesSortOptions =
val EpisodeSortOptions =
listOf(
ItemSortBy.SORT_NAME,
+ ItemSortBy.PREMIERE_DATE,
ItemSortBy.DATE_CREATED,
ItemSortBy.DATE_PLAYED,
ItemSortBy.AIRED_EPISODE_ORDER,
+ ItemSortBy.COMMUNITY_RATING,
+ ItemSortBy.CRITIC_RATING,
ItemSortBy.OFFICIAL_RATING,
ItemSortBy.RUNTIME,
+ ItemSortBy.PLAY_COUNT,
ItemSortBy.RANDOM,
)
val VideoSortOptions =
listOf(
ItemSortBy.SORT_NAME,
+ ItemSortBy.PREMIERE_DATE,
ItemSortBy.DATE_CREATED,
ItemSortBy.DATE_PLAYED,
+ ItemSortBy.COMMUNITY_RATING,
+ ItemSortBy.CRITIC_RATING,
+ ItemSortBy.OFFICIAL_RATING,
ItemSortBy.RUNTIME,
ItemSortBy.PLAY_COUNT,
ItemSortBy.RANDOM,
@@ -80,6 +93,7 @@ val PlaylistSortOptions =
ItemSortBy.DATE_PLAYED,
ItemSortBy.COMMUNITY_RATING,
ItemSortBy.CRITIC_RATING,
+ ItemSortBy.OFFICIAL_RATING,
ItemSortBy.RUNTIME,
ItemSortBy.PLAY_COUNT,
ItemSortBy.RANDOM,
@@ -180,3 +194,31 @@ fun getStringRes(sort: ItemSortBy): Int =
else -> throw IllegalArgumentException("Unsupported sort option: $sort")
}
+
+@Composable
+fun rememberSortOptions(collectionType: CollectionType): List =
+ remember(collectionType) {
+ when (collectionType) {
+ CollectionType.MOVIES -> MovieSortOptions
+ CollectionType.TVSHOWS -> SeriesSortOptions
+ CollectionType.MUSIC -> AlbumSortOptions
+ CollectionType.BOXSETS -> BoxSetSortOptions
+ else -> VideoSortOptions
+ }
+ }
+
+@Composable
+fun rememberSortOptions(type: BaseItemKind): List =
+ remember(type) {
+ when (type) {
+ BaseItemKind.MOVIE -> MovieSortOptions
+ BaseItemKind.SERIES -> SeriesSortOptions
+ BaseItemKind.SEASON -> SeriesSortOptions
+ BaseItemKind.EPISODE -> EpisodeSortOptions
+ BaseItemKind.AUDIO -> SongSortOptions
+ BaseItemKind.MUSIC_ALBUM -> AlbumSortOptions
+ BaseItemKind.MUSIC_ARTIST -> ArtistSortOptions
+ BaseItemKind.BOX_SET -> BoxSetSortOptions
+ else -> VideoSortOptions
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt
index 1d42848b..00713ddb 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt
@@ -73,7 +73,7 @@ import com.github.damontecres.wholphin.ui.playback.isForwardButton
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
-import kotlinx.coroutines.Dispatchers
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
@@ -141,7 +141,7 @@ fun CardGrid(
{ index: Int ->
if (DEBUG) Timber.v("focusOn: focusedIndex=$currentFocusedIndex, index=$index")
if (index != currentFocusedIndex) {
- previouslyFocusedIndex = focusedIndex
+ previouslyFocusedIndex = currentFocusedIndex
}
focusedIndex = index
}
@@ -217,7 +217,7 @@ fun CardGrid(
{ letter: Char ->
scope.launch(ExceptionHandler()) {
val jumpPosition =
- withContext(Dispatchers.IO) {
+ withContext(WholphinDispatchers.IO) {
letterPosition.invoke(letter)
}
Timber.d("Alphabet jump to $jumpPosition")
@@ -257,10 +257,11 @@ fun CardGrid(
val newPosition = previouslyFocusedIndex
if (DEBUG) Timber.d("Back long pressed: newPosition=$newPosition")
if (newPosition > 0) {
- focusOn(newPosition)
scope.launch(ExceptionHandler()) {
- gridState.scrollToItem(newPosition, -columns)
- firstFocus.tryRequestFocus()
+ pager.getOrNull(newPosition)
+ gridState.scrollToItem(newPosition)
+ focusOn(newPosition)
+ alphabetFocus = true
}
}
return@onKeyEvent true
@@ -344,7 +345,7 @@ fun CardGrid(
items(pager.size) { index ->
val item = pager[index]
val details =
- remember(index, item) {
+ remember(index, item, cardWidthPx, columns) {
val mod =
if ((index == currentFocusedIndex) or (currentFocusedIndex < 0 && index == 0)) {
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
@@ -514,7 +515,7 @@ fun AlphabetButtons(
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
- val index = letters.indexOf(currentLetter)
+ val index = remember(letters, currentLetter) { letters.indexOf(currentLetter) }
LaunchedEffect(currentLetter) {
scope.launch(ExceptionHandler()) {
val firstVisibleItemIndex = listState.firstVisibleItemIndex
@@ -522,7 +523,7 @@ fun AlphabetButtons(
listState.layoutInfo.visibleItemsInfo
.lastOrNull()
?.index ?: -1
- if (index !in firstVisibleItemIndex..lastVisibleItemIndex) {
+ if (index >= 0 && index !in firstVisibleItemIndex..lastVisibleItemIndex) {
listState.animateScrollToItem(index)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt
index 7d5c262c..ccd7e556 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -32,7 +33,7 @@ fun CollectionFolderGeneric(
sortOptions: List = VideoSortOptions,
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
- var showHeader by remember { mutableStateOf(true) }
+ var showHeader by rememberSaveable { mutableStateOf(true) }
val viewOptions =
remember(usePosters) {
if (usePosters) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt
index f1e575b3..18af298b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt
@@ -1,25 +1,15 @@
package com.github.damontecres.wholphin.ui.detail
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.expandVertically
-import androidx.compose.animation.shrinkVertically
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -32,20 +22,23 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.components.CollectionFolderView
import com.github.damontecres.wholphin.ui.components.ErrorMessage
-import com.github.damontecres.wholphin.ui.components.TabRow
+import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.components.TabDetails
+import com.github.damontecres.wholphin.ui.components.TabbedPage
import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule
import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid
import com.github.damontecres.wholphin.ui.launchIO
-import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination
-import com.github.damontecres.wholphin.ui.tryRequestFocus
-import com.github.damontecres.wholphin.util.RememberTabManager
+import com.github.damontecres.wholphin.ui.util.StringStringProvider
+import com.github.damontecres.wholphin.util.DataLoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
+import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@@ -56,24 +49,35 @@ class LiveTvCollectionViewModel
val api: ApiClient,
val serverRepository: ServerRepository,
val navigationManager: NavigationManager,
- val rememberTabManager: RememberTabManager,
val backdropService: BackdropService,
- ) : ViewModel(),
- RememberTabManager by rememberTabManager {
- val recordingFolders = MutableStateFlow>(emptyList())
+ ) : ViewModel() {
+ val state = MutableStateFlow(LiveTvRecordingFolderState())
init {
viewModelScope.launchIO {
- val folders =
- api.liveTvApi
- .getRecordingFolders(userId = serverRepository.currentUser?.id)
- .content.items
- .map { TabId(it.name ?: "Recordings", it.id) }
- recordingFolders.value = folders
+ try {
+ val folders =
+ api.liveTvApi
+ .getRecordingFolders(userId = serverRepository.currentUser?.id)
+ .content.items
+ .map { TabId(it.name ?: "Recordings", it.id) }
+ state.value =
+ LiveTvRecordingFolderState(DataLoadingState.Success(folders))
+ } catch (ex: CancellationException) {
+ throw ex
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error fetching recording folders")
+ state.value =
+ LiveTvRecordingFolderState(DataLoadingState.Error(ex))
+ }
}
}
}
+data class LiveTvRecordingFolderState(
+ val folders: DataLoadingState> = DataLoadingState.Pending,
+)
+
data class TabId(
val title: String,
val id: UUID,
@@ -86,102 +90,91 @@ fun CollectionFolderLiveTv(
modifier: Modifier = Modifier,
viewModel: LiveTvCollectionViewModel = hiltViewModel(),
) {
- val rememberedTabIndex =
- remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
- val folders by viewModel.recordingFolders.collectAsState()
+ val state by viewModel.state.collectAsState()
- val tvGuideStr = stringResource(R.string.tv_guide)
- val tvDvrStr = stringResource(R.string.tv_dvr_schedule)
- val tabs =
- remember(folders) {
- listOf(
- TabId(tvGuideStr, UUID.randomUUID()),
- TabId(tvDvrStr, UUID.randomUUID()),
- ) + folders
+ when (val st = state.folders) {
+ is DataLoadingState.Error -> {
+ ErrorMessage(st, modifier)
}
- var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
- val focusRequester = remember { FocusRequester() }
- val tabFocusRequesters = remember(tabs.size) { List(tabs.size) { FocusRequester() } }
-
- val firstTabFocusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
-
- LaunchedEffect(selectedTabIndex) {
- logTab("livetv", selectedTabIndex)
- viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
- viewModel.backdropService.clearBackdrop()
- }
- val onClickItem = { position: Int, item: BaseItem ->
- viewModel.navigationManager.navigateTo(item.destination())
- }
-
- var showHeader by rememberSaveable { mutableStateOf(true) }
-
- LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
- Column(
- modifier = modifier,
- ) {
- AnimatedVisibility(
- showHeader,
- enter = expandVertically(),
- exit = shrinkVertically(),
- ) {
- TabRow(
- selectedTabIndex = selectedTabIndex,
- modifier =
- Modifier
- .padding(vertical = 16.dp)
- .focusRequester(firstTabFocusRequester),
- tabs = tabs.map { it.title },
- onClick = { selectedTabIndex = it },
- focusRequesters = tabFocusRequesters,
- )
+ DataLoadingState.Loading,
+ DataLoadingState.Pending,
+ -> {
+ LoadingPage(modifier)
}
- when (selectedTabIndex) {
- 0 -> {
- TvGuideGrid(
- onRowPosition = {
- showHeader = it <= 0
- },
- Modifier
- .fillMaxSize()
- .focusRequester(focusRequester),
- )
- }
- 1 -> {
- DvrSchedule(
- requestFocusAfterLoading = true,
- focusRequesterOnEmpty = tabFocusRequesters[1],
- modifier =
- Modifier
- .fillMaxSize()
- .focusRequester(focusRequester),
- )
- }
+ is DataLoadingState.Success> -> {
+ val tabs =
+ remember {
+ listOf(
+ TabDetails(R.string.tv_guide),
+ TabDetails(R.string.tv_dvr_schedule),
+ ) +
+ st.data.map {
+ TabDetails(StringStringProvider(it.title))
+ }
+ }
+ val onClickItem =
+ remember {
+ { position: Int, item: BaseItem ->
+ viewModel.navigationManager.navigateTo(item.destination())
+ }
+ }
- else -> {
- val folderIndex = selectedTabIndex - 2
- if (folderIndex in folders.indices) {
- CollectionFolderView(
- preferences = preferences,
- onClickItem = onClickItem,
- itemId = folders[folderIndex].id,
- initialFilter = CollectionFolderFilter(),
- showTitle = false,
- recursive = false,
- sortOptions = VideoSortOptions,
- modifier = Modifier,
- positionCallback = { columns, position ->
- showHeader = position < columns
- },
- playEnabled = false,
- defaultViewOptions = ViewOptions(),
- focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
- )
- } else {
- ErrorMessage("Invalid tab index $selectedTabIndex", null)
+ var showHeader by rememberSaveable { mutableStateOf(true) }
+ TabbedPage(
+ itemId = destination.itemId.toString(),
+ tabs = tabs,
+ modifier = modifier,
+ showTabs = showHeader,
+ ) { tabIndex, tabDetails ->
+ when (tabIndex) {
+ 0 -> {
+ TvGuideGrid(
+ preferences = preferences,
+ onRowPosition = {
+ showHeader = it <= 0
+ },
+ Modifier
+ .fillMaxSize()
+ .focusRequester(tabDetails.contentFocusRequester),
+ )
+ }
+
+ 1 -> {
+ DvrSchedule(
+ requestFocusAfterLoading = true,
+ focusRequesterOnEmpty = tabDetails.tabFocusRequester,
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .focusRequester(tabDetails.contentFocusRequester),
+ )
+ }
+
+ else -> {
+ val folderIndex = tabIndex - 2
+ if (folderIndex in st.data.indices) {
+ CollectionFolderView(
+ preferences = preferences,
+ onClickItem = onClickItem,
+ itemId = st.data[folderIndex].id,
+ initialFilter = CollectionFolderFilter(),
+ showTitle = false,
+ recursive = false,
+ sortOptions = VideoSortOptions,
+ modifier = Modifier.focusRequester(tabDetails.contentFocusRequester),
+ positionCallback = { columns, position ->
+ showHeader = position < columns
+ },
+ playEnabled = false,
+ defaultViewOptions = ViewOptions(),
+ focusRequesterOnEmpty = tabDetails.tabFocusRequester,
+ )
+ } else {
+ ErrorMessage("Invalid tab index $tabIndex", null)
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt
index 564ac66a..cb1d1812 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt
@@ -1,24 +1,14 @@
package com.github.damontecres.wholphin.ui.detail
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.expandVertically
-import androidx.compose.animation.shrinkVertically
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
@@ -28,14 +18,15 @@ import com.github.damontecres.wholphin.ui.components.CollectionFolderView
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedMovie
-import com.github.damontecres.wholphin.ui.components.TabRow
+import com.github.damontecres.wholphin.ui.components.TabDetails
+import com.github.damontecres.wholphin.ui.components.TabbedPage
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
-import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.CollectionType
@Composable
fun CollectionFolderMovie(
@@ -44,51 +35,25 @@ fun CollectionFolderMovie(
modifier: Modifier = Modifier,
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
- val rememberedTabIndex =
- remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) }
-
val tabs =
- listOf(
- stringResource(R.string.recommended),
- stringResource(R.string.library),
- stringResource(R.string.collections),
- stringResource(R.string.genres),
- )
- var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
- val focusRequester = remember { FocusRequester() }
- val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
-
- val firstTabFocusRequester = remember { FocusRequester() }
-// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
-
- LaunchedEffect(selectedTabIndex) {
- logTab("movie", selectedTabIndex)
- preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
- preferencesViewModel.backdropService.clearBackdrop()
- }
+ remember {
+ listOf(
+ TabDetails(R.string.recommended),
+ TabDetails(R.string.library),
+ TabDetails(R.string.collections),
+ TabDetails(R.string.genres),
+ )
+ }
var showHeader by rememberSaveable { mutableStateOf(true) }
- Column(
+ TabbedPage(
+ itemId = destination.itemId.toString(),
+ tabs = tabs,
modifier = modifier,
- ) {
- AnimatedVisibility(
- showHeader,
- enter = expandVertically(),
- exit = shrinkVertically(),
- ) {
- TabRow(
- selectedTabIndex = selectedTabIndex,
- modifier =
- Modifier
- .padding(vertical = 16.dp)
- .focusRequester(firstTabFocusRequester),
- tabs = tabs,
- onClick = { selectedTabIndex = it },
- focusRequesters = tabFocusRequesters,
- )
- }
- when (selectedTabIndex) {
+ showTabs = showHeader,
+ ) { tabIndex, tabDetails ->
+ when (tabIndex) {
// Recommended
0 -> {
RecommendedMovie(
@@ -100,7 +65,7 @@ fun CollectionFolderMovie(
modifier =
Modifier
.fillMaxSize()
- .focusRequester(focusRequester),
+ .focusRequester(tabDetails.contentFocusRequester),
)
}
@@ -127,12 +92,12 @@ fun CollectionFolderMovie(
modifier =
Modifier
.fillMaxSize()
- .focusRequester(focusRequester),
+ .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = true,
- focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
+ focusRequesterOnEmpty = tabDetails.tabFocusRequester,
)
}
@@ -159,12 +124,12 @@ fun CollectionFolderMovie(
modifier =
Modifier
.fillMaxSize()
- .focusRequester(focusRequester),
+ .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = false,
- focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
+ focusRequesterOnEmpty = tabDetails.tabFocusRequester,
)
}
@@ -173,15 +138,16 @@ fun CollectionFolderMovie(
GenreCardGrid(
itemId = destination.itemId,
includeItemTypes = listOf(BaseItemKind.MOVIE),
+ collectionType = CollectionType.MOVIES,
modifier =
Modifier
.fillMaxSize()
- .focusRequester(focusRequester),
+ .focusRequester(tabDetails.contentFocusRequester),
)
}
else -> {
- ErrorMessage("Invalid tab index $selectedTabIndex", null)
+ ErrorMessage("Invalid tab index $tabIndex", null)
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt
index 421e7ad3..9ab7e33f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt
@@ -1,24 +1,14 @@
package com.github.damontecres.wholphin.ui.detail
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.expandVertically
-import androidx.compose.animation.shrinkVertically
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -27,6 +17,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
+import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.MusicService
@@ -36,23 +27,23 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.GridClickActions
import com.github.damontecres.wholphin.ui.components.RecommendedMusic
-import com.github.damontecres.wholphin.ui.components.TabRow
+import com.github.damontecres.wholphin.ui.components.TabDetails
+import com.github.damontecres.wholphin.ui.components.TabbedPage
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
import com.github.damontecres.wholphin.ui.data.AlbumSortOptions
import com.github.damontecres.wholphin.ui.data.ArtistSortOptions
import com.github.damontecres.wholphin.ui.data.SongSortOptions
import com.github.damontecres.wholphin.ui.launchDefault
-import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
-import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID
@@ -65,10 +56,8 @@ class CollectionFolderMusicViewModel
private val musicService: MusicService,
private val navigationManager: NavigationManager,
val backdropService: BackdropService,
- private val rememberTabManager: RememberTabManager,
@Assisted private val itemId: UUID,
- ) : ViewModel(),
- RememberTabManager by rememberTabManager {
+ ) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(itemId: UUID): CollectionFolderMusicViewModel
@@ -86,13 +75,7 @@ class CollectionFolderMusicViewModel
index: Int,
item: BaseItem,
) {
- if (item.type == BaseItemKind.AUDIO) {
- viewModelScope.launchDefault {
- musicService.setQueue(listOf(item), false)
- }
- } else {
- navigationManager.navigateTo(item.destination())
- }
+ navigationManager.navigateTo(item.destination())
}
fun onClickPlayAll(shuffle: Boolean) {
@@ -131,30 +114,16 @@ fun CollectionFolderMusic(
creationCallback = { it.create(destination.itemId) },
),
) {
- val rememberedTabIndex =
- remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
-
val tabs =
- listOf(
- stringResource(R.string.recommended),
- stringResource(R.string.albums),
- stringResource(R.string.artists),
- stringResource(R.string.genres),
- stringResource(R.string.songs),
- )
- var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
- val focusRequester = remember { FocusRequester() }
- val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
-
- val firstTabFocusRequester = remember { FocusRequester() }
-// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
-
- LaunchedEffect(selectedTabIndex) {
- logTab("music", selectedTabIndex)
- viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
- viewModel.backdropService.clearBackdrop()
- }
-
+ remember {
+ listOf(
+ TabDetails(R.string.recommended),
+ TabDetails(R.string.albums),
+ TabDetails(R.string.artists),
+ TabDetails(R.string.genres),
+ TabDetails(R.string.songs),
+ )
+ }
var showHeader by rememberSaveable { mutableStateOf(true) }
val actions =
@@ -166,27 +135,13 @@ fun CollectionFolderMusic(
onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton,
)
}
-
- Column(
+ TabbedPage(
+ itemId = destination.itemId.toString(),
+ tabs = tabs,
modifier = modifier,
- ) {
- AnimatedVisibility(
- showHeader,
- enter = expandVertically(),
- exit = shrinkVertically(),
- ) {
- TabRow(
- selectedTabIndex = selectedTabIndex,
- modifier =
- Modifier
- .padding(vertical = 16.dp)
- .focusRequester(firstTabFocusRequester),
- tabs = tabs,
- onClick = { selectedTabIndex = it },
- focusRequesters = tabFocusRequesters,
- )
- }
- when (selectedTabIndex) {
+ showTabs = showHeader,
+ ) { tabIndex, tabDetails ->
+ when (tabIndex) {
// Recommended
0 -> {
RecommendedMusic(
@@ -198,7 +153,7 @@ fun CollectionFolderMusic(
modifier =
Modifier
.fillMaxSize()
- .focusRequester(focusRequester),
+ .focusRequester(tabDetails.contentFocusRequester),
)
}
@@ -223,12 +178,12 @@ fun CollectionFolderMusic(
modifier =
Modifier
.fillMaxSize()
- .focusRequester(focusRequester),
+ .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = true,
- focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
+ focusRequesterOnEmpty = tabDetails.tabFocusRequester,
)
}
@@ -243,7 +198,7 @@ fun CollectionFolderMusic(
CollectionFolderFilter(
filter =
GetItemsFilter(
- includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST),
+ override = GetItemsFilterOverride.ARTIST,
),
),
showTitle = false,
@@ -253,12 +208,12 @@ fun CollectionFolderMusic(
modifier =
Modifier
.fillMaxSize()
- .focusRequester(focusRequester),
+ .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = false,
- focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
+ focusRequesterOnEmpty = tabDetails.tabFocusRequester,
)
}
@@ -267,10 +222,11 @@ fun CollectionFolderMusic(
GenreCardGrid(
itemId = destination.itemId,
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
+ collectionType = CollectionType.MUSIC,
modifier =
Modifier
.fillMaxSize()
- .focusRequester(focusRequester),
+ .focusRequester(tabDetails.contentFocusRequester),
)
}
@@ -295,17 +251,17 @@ fun CollectionFolderMusic(
modifier =
Modifier
.fillMaxSize()
- .focusRequester(focusRequester),
+ .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = true,
- focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
+ focusRequesterOnEmpty = tabDetails.tabFocusRequester,
)
}
else -> {
- ErrorMessage("Invalid tab index $selectedTabIndex", null)
+ ErrorMessage("Invalid tab index $tabIndex", null)
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt
index ce4c7ec8..02ecfd9f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt
@@ -5,6 +5,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -48,7 +49,7 @@ fun CollectionFolderPhotoAlbum(
)
},
) {
- var showHeader by remember { mutableStateOf(true) }
+ var showHeader by rememberSaveable { mutableStateOf(true) }
val state by viewModel.state.collectAsState()
CollectionFolderView(
preferences = preferences,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt
index 30a40fcd..70acd69b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt
@@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.ui.detail
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -24,7 +24,7 @@ fun CollectionFolderPlaylist(
filter: CollectionFolderFilter = CollectionFolderFilter(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
- var showHeader by remember { mutableStateOf(true) }
+ var showHeader by rememberSaveable { mutableStateOf(true) }
CollectionFolderView(
preferences = preferences,
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt
index 0279bd72..95fcaf00 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt
@@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.ui.detail
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -24,7 +24,7 @@ fun CollectionFolderRecordings(
filter: CollectionFolderFilter = CollectionFolderFilter(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
- var showHeader by remember { mutableStateOf(true) }
+ var showHeader by rememberSaveable { mutableStateOf(true) }
CollectionFolderView(
preferences = preferences,
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt
index af75b638..2f8fb909 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt
@@ -1,101 +1,77 @@
package com.github.damontecres.wholphin.ui.detail
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.expandVertically
-import androidx.compose.animation.shrinkVertically
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.filter.DefaultTvFilterOptions
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.components.CollectionFolderView
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedTvShow
import com.github.damontecres.wholphin.ui.components.StudioCardGrid
-import com.github.damontecres.wholphin.ui.components.TabRow
+import com.github.damontecres.wholphin.ui.components.TabDetails
+import com.github.damontecres.wholphin.ui.components.TabbedPage
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
-import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination
-import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
-import com.github.damontecres.wholphin.ui.tryRequestFocus
+import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.CollectionType
+import javax.inject.Inject
+
+@HiltViewModel
+class CollectionFolderTvViewModel
+ @Inject
+ constructor(
+ val navigationManager: NavigationManager,
+ ) : ViewModel()
@Composable
fun CollectionFolderTv(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
- preferencesViewModel: PreferencesViewModel = hiltViewModel(),
+ viewModel: CollectionFolderTvViewModel = hiltViewModel(),
) {
- val rememberedTabIndex =
- remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) }
-
val tabs =
- listOf(
- stringResource(R.string.recommended),
- stringResource(R.string.library),
- stringResource(R.string.genres),
- stringResource(R.string.studios),
- )
- var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
- val focusRequester = remember { FocusRequester() }
- val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
-
- val firstTabFocusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
-
- LaunchedEffect(selectedTabIndex) {
- logTab("tv", selectedTabIndex)
- preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
- preferencesViewModel.backdropService.clearBackdrop()
- }
-
- val onClickItem = { item: BaseItem ->
- preferencesViewModel.navigationManager.navigateTo(item.destination())
- }
+ remember {
+ listOf(
+ TabDetails(R.string.recommended),
+ TabDetails(R.string.library),
+ TabDetails(R.string.genres),
+ TabDetails(R.string.studios),
+ )
+ }
+ val onClickItem =
+ remember {
+ { position: Int, item: BaseItem ->
+ viewModel.navigationManager.navigateTo(item.destination())
+ }
+ }
var showHeader by rememberSaveable { mutableStateOf(true) }
- Column(
+ TabbedPage(
+ itemId = destination.itemId.toString(),
+ tabs = tabs,
modifier = modifier,
- ) {
- AnimatedVisibility(
- showHeader,
- enter = expandVertically(),
- exit = shrinkVertically(),
- ) {
- TabRow(
- selectedTabIndex = selectedTabIndex,
- modifier =
- Modifier
- .padding(vertical = 16.dp)
- .focusRequester(firstTabFocusRequester),
- tabs = tabs,
- onClick = { selectedTabIndex = it },
- focusRequesters = tabFocusRequesters,
- )
- }
- when (selectedTabIndex) {
+ showTabs = showHeader,
+ ) { tabIndex, tabDetails ->
+ when (tabIndex) {
// Recommended
0 -> {
RecommendedTvShow(
@@ -107,7 +83,7 @@ fun CollectionFolderTv(
modifier =
Modifier
.fillMaxSize()
- .focusRequester(focusRequester),
+ .focusRequester(tabDetails.contentFocusRequester),
)
}
@@ -131,15 +107,13 @@ fun CollectionFolderTv(
modifier =
Modifier
.fillMaxSize()
- .focusRequester(focusRequester),
+ .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
- onClickItem = { _, item ->
- preferencesViewModel.navigationManager.navigateTo(item.destination())
- },
+ onClickItem = onClickItem,
playEnabled = false,
- focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
+ focusRequesterOnEmpty = tabDetails.tabFocusRequester,
)
}
@@ -148,10 +122,11 @@ fun CollectionFolderTv(
GenreCardGrid(
itemId = destination.itemId,
includeItemTypes = listOf(BaseItemKind.SERIES),
+ collectionType = CollectionType.TVSHOWS,
modifier =
Modifier
.fillMaxSize()
- .focusRequester(focusRequester),
+ .focusRequester(tabDetails.contentFocusRequester),
)
}
@@ -163,12 +138,12 @@ fun CollectionFolderTv(
modifier =
Modifier
.fillMaxSize()
- .focusRequester(focusRequester),
+ .focusRequester(tabDetails.contentFocusRequester),
)
}
else -> {
- ErrorMessage("Invalid tab index $selectedTabIndex", null)
+ ErrorMessage("Invalid tab index $tabIndex", null)
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
index 0f859bb8..7c8268b0 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
@@ -2,6 +2,10 @@ package com.github.damontecres.wholphin.ui.detail
import android.content.Context
import android.hardware.display.DisplayManager
+import android.media.AudioDeviceCallback
+import android.media.AudioDeviceInfo
+import android.media.AudioFormat
+import android.media.AudioManager
import android.os.Build
import android.util.Log
import android.view.Display
@@ -12,11 +16,14 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -25,7 +32,6 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
@@ -33,23 +39,23 @@ import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.BuildConfig
-import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
-import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import org.acra.util.versionCodeLong
-import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.api.client.extensions.clientLogApi
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
-import timber.log.Timber
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.Date
@@ -59,17 +65,18 @@ import javax.inject.Inject
class DebugViewModel
@Inject
constructor(
+ @param:ApplicationContext private val context: Context,
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
val clientInfo: ClientInfo,
val deviceInfo: DeviceInfo,
) : ViewModel() {
+ val state = MutableStateFlow(DebugState())
val itemPlaybacks = MutableStateFlow>(emptyList())
val logcat = MutableStateFlow>(emptyList())
val supportedModes by lazy {
- val displayManager =
- MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
+ val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
display.supportedModes.orEmpty()
}
@@ -103,6 +110,59 @@ class DebugViewModel
}
init {
+ viewModelScope.launchDefault {
+ val buildTime = Date(BuildConfig.BUILD_TIME)
+ val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
+ val installInfo =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val installSource =
+ context.packageManager.getInstallSourceInfo(context.packageName)
+ buildList {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ add("Install source: ${installSource.packageSource}")
+ }
+ add("Installer: ${installSource.installingPackageName}")
+ add("Initiator: ${installSource.initiatingPackageName}")
+ }
+ } else {
+ listOf(
+ context.packageManager
+ .getInstallerPackageName(context.packageName)
+ .toString(),
+ )
+ }
+
+ val appInfo =
+ listOf(
+ "Version Name: ${pkgInfo.versionName}",
+ "Version Code: ${pkgInfo.versionCodeLong}",
+ "ClientInfo: $clientInfo",
+ "Build type: ${BuildConfig.BUILD_TYPE}",
+ "Build flavor: ${BuildConfig.FLAVOR}",
+ "Build time: $buildTime",
+ "FFMPEG included: $ffmpegIncluded",
+ "AV1 included: $av1Included",
+ "libmpv loaded: $libMpvLoaded",
+ "Debug enabled: ${BuildConfig.DEBUG}",
+ "ABIs: ${Build.SUPPORTED_ABIS.toList()}",
+ ) + installInfo
+ state.update { it.copy(appInfo = appInfo) }
+ }
+ viewModelScope.launchDefault {
+ val deviceInfoList =
+ listOf(
+ "DeviceInfo: $deviceInfo",
+ "Manufacturer: ${Build.MANUFACTURER}",
+ "Model: ${Build.MODEL}",
+ "API Level: ${Build.VERSION.SDK_INT}",
+ )
+ state.update {
+ it.copy(
+ deviceInfo = deviceInfoList,
+ displayModes = supportedModes.map { it.toString() },
+ )
+ }
+ }
viewModelScope.launchIO {
serverRepository.currentUser?.rowId?.let {
val results = itemPlaybackDao.getItems(it)
@@ -113,74 +173,84 @@ class DebugViewModel
val logcat = getLogCatLines()
this@DebugViewModel.logcat.value = logcat
}
+ viewModelScope.launchDefault {
+ val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ val callback =
+ object : AudioDeviceCallback() {
+ override fun onAudioDevicesAdded(addedDevices: Array) {
+ state.update {
+ it.copy(
+ audioInfo =
+ it.audioInfo.toMutableList().apply {
+ addAll(
+ addedDevices
+ .filter { it.isSink }
+ .map { it.details },
+ )
+ },
+ )
+ }
+ }
+
+ override fun onAudioDevicesRemoved(removedDevices: Array) {
+ state.update {
+ it.copy(
+ audioInfo =
+ it.audioInfo.toMutableList().apply {
+ removeAll(removedDevices.map { it.details })
+ },
+ )
+ }
+ }
+ }
+ audioManager.registerAudioDeviceCallback(callback, null)
+ addCloseable { audioManager.unregisterAudioDeviceCallback(callback) }
+ }
}
companion object {
- fun getLogCatLines(): List {
- val lineCount = 500
- val args =
- buildList {
- add("logcat")
- add("-d")
- add("-t")
- add(lineCount.toString())
- addAll(THIRD_PARTY_TAGS)
- add("*:V")
- }
- val process = ProcessBuilder().command(args).redirectErrorStream(true).start()
- val logLines = mutableListOf()
- try {
- val reader = BufferedReader(InputStreamReader(process.inputStream))
- var count = 0
-
- while (count < lineCount) {
- val line = reader.readLine()
- if (line != null) {
- val level = line.split(Regex("\\s+")).getOrNull(4)
- val logLevel =
- when (level?.uppercase()) {
- "V" -> Log.VERBOSE
- "D" -> Log.DEBUG
- "I" -> Log.INFO
- "W" -> Log.WARN
- "E" -> Log.ERROR
- else -> Log.VERBOSE
- }
- logLines.add(LogcatLine(logLevel, line))
- } else {
- break
+ suspend fun getLogCatLines(): List =
+ withContext(WholphinDispatchers.IO) {
+ val lineCount = 500
+ val args =
+ buildList {
+ add("logcat")
+ add("-d")
+ add("-t")
+ add(lineCount.toString())
+ addAll(THIRD_PARTY_TAGS)
+ add("*:V")
}
- count++
+ val process = ProcessBuilder().command(args).redirectErrorStream(true).start()
+ val logLines = mutableListOf()
+ try {
+ val reader = BufferedReader(InputStreamReader(process.inputStream))
+ var count = 0
+
+ while (count < lineCount) {
+ val line = reader.readLine()
+ if (line != null) {
+ val level = line.split(Regex("\\s+")).getOrNull(4)
+ val logLevel =
+ when (level?.uppercase()) {
+ "V" -> Log.VERBOSE
+ "D" -> Log.DEBUG
+ "I" -> Log.INFO
+ "W" -> Log.WARN
+ "E" -> Log.ERROR
+ else -> Log.VERBOSE
+ }
+ logLines.add(LogcatLine(logLevel, line))
+ } else {
+ break
+ }
+ count++
+ }
+ } finally {
+ process.destroy()
}
- } finally {
- process.destroy()
+ return@withContext logLines
}
- return logLines
- }
-
- fun ViewModel.sendAppLogs(
- context: Context,
- api: ApiClient,
- clientInfo: ClientInfo?,
- deviceInfo: DeviceInfo?,
- ) {
- viewModelScope.launchIO(ExceptionHandler(true)) {
- val logcat = getLogCatLines().joinToString("\n") { it.text }
- val body =
- """
- Send App Logs
- clientInfo=$clientInfo
- deviceInfo=$deviceInfo
- manufacturer=${Build.MANUFACTURER}
- model=${Build.MODEL}
- apiLevel=${Build.VERSION.SDK_INT}
-
- """.trimIndent()
- Timber.w(body)
- val response by api.clientLogApi.logFile(body + logcat)
- showToast(context, "Sent! Filename=${response.fileName}")
- }
- }
}
}
@@ -189,18 +259,144 @@ data class LogcatLine(
val text: String,
)
+data class DebugState(
+ val appInfo: List = emptyList(),
+ val deviceInfo: List = emptyList(),
+ val displayModes: List = emptyList(),
+ val audioInfo: List = emptyList(),
+)
+
+val AudioDeviceInfo.details: String
+ get() {
+ val typeName =
+ when (type) {
+ AudioDeviceInfo.TYPE_HDMI -> "HDMI"
+ AudioDeviceInfo.TYPE_HDMI_ARC -> "ARC"
+ AudioDeviceInfo.TYPE_HDMI_EARC -> "eARC"
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker"
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> "Speaker Safe"
+ else -> "N/A"
+ }
+ val addressStr =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ address
+ } else {
+ null
+ }
+ val encodings =
+ encodings.map { enc ->
+ when (enc) {
+ AudioFormat.ENCODING_INVALID -> "INVALID"
+
+ AudioFormat.ENCODING_PCM_16BIT -> "PCM_16BIT"
+
+ AudioFormat.ENCODING_PCM_8BIT -> "PCM_8BIT"
+
+ AudioFormat.ENCODING_PCM_FLOAT -> "PCM_FLOAT"
+
+ AudioFormat.ENCODING_AC3 -> "AC3"
+
+ AudioFormat.ENCODING_E_AC3 -> "E_AC3"
+
+ AudioFormat.ENCODING_DTS -> "DTS"
+
+ AudioFormat.ENCODING_DTS_HD -> "DTS_HD"
+
+ AudioFormat.ENCODING_MP3 -> "MP3"
+
+ AudioFormat.ENCODING_AAC_LC -> "AAC_LC"
+
+ AudioFormat.ENCODING_AAC_HE_V1 -> "AAC_HE_V1"
+
+ AudioFormat.ENCODING_AAC_HE_V2 -> "AAC_HE_V2"
+
+ AudioFormat.ENCODING_IEC61937 -> "IEC61937"
+
+ AudioFormat.ENCODING_DOLBY_TRUEHD -> "DOLBY_TRUEHD"
+
+ AudioFormat.ENCODING_AAC_ELD -> "AAC_ELD"
+
+ AudioFormat.ENCODING_AAC_XHE -> "AAC_XHE"
+
+ AudioFormat.ENCODING_AC4 -> "AC4"
+
+ // AudioFormat.ENCODING_AC4_L4->"AC4_L4"
+
+ AudioFormat.ENCODING_E_AC3_JOC -> "E_AC3_JOC"
+
+ AudioFormat.ENCODING_DOLBY_MAT -> "DOLBY_MAT"
+
+ AudioFormat.ENCODING_OPUS -> "OPUS"
+
+ AudioFormat.ENCODING_PCM_24BIT_PACKED -> "PCM_24BIT_PACKED"
+
+ AudioFormat.ENCODING_PCM_32BIT -> "PCM_32BIT"
+
+ AudioFormat.ENCODING_MPEGH_BL_L3 -> "MPEGH_BL_L3"
+
+ AudioFormat.ENCODING_MPEGH_BL_L4 -> "MPEGH_BL_L4"
+
+ AudioFormat.ENCODING_MPEGH_LC_L3 -> "MPEGH_LC_L3"
+
+ AudioFormat.ENCODING_MPEGH_LC_L4 -> "MPEGH_LC_L4"
+
+ AudioFormat.ENCODING_DTS_UHD_P1 -> "DTS_UHD_P1"
+
+ AudioFormat.ENCODING_DRA -> "DRA"
+
+ AudioFormat.ENCODING_DTS_HD_MA -> "DTS_HD_MA"
+
+ AudioFormat.ENCODING_DTS_UHD_P2 -> "DTS_UHD_P2"
+
+ AudioFormat.ENCODING_DSD -> "DSD"
+
+ AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_AAC -> "IAMF_BASE_ENHANCED_PROFILE_AAC"
+
+ AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_FLAC -> "IAMF_BASE_ENHANCED_PROFILE_FLAC"
+
+ AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_OPUS -> "IAMF_BASE_ENHANCED_PROFILE_OPUS"
+
+ AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_PCM -> "IAMF_BASE_ENHANCED_PROFILE_PCM"
+
+ AudioFormat.ENCODING_IAMF_BASE_PROFILE_AAC -> "IAMF_BASE_PROFILE_AAC"
+
+ AudioFormat.ENCODING_IAMF_BASE_PROFILE_FLAC -> "IAMF_BASE_PROFILE_FLAC"
+
+ AudioFormat.ENCODING_IAMF_BASE_PROFILE_OPUS -> "IAMF_BASE_PROFILE_OPUS"
+
+ AudioFormat.ENCODING_IAMF_BASE_PROFILE_PCM -> "IAMF_BASE_PROFILE_PCM"
+
+ AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_AAC -> "IAMF_SIMPLE_PROFILE_AAC"
+
+ AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_FLAC -> "IAMF_SIMPLE_PROFILE_FLAC"
+
+ AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_OPUS -> "IAMF_SIMPLE_PROFILE_OPUS"
+
+ AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_PCM -> "IAMF_SIMPLE_PROFILE_PCM"
+
+ else -> "invalid encoding $enc"
+ }
+ }
+ return "AudioDeviceInfo(id=$id, type=$type ($typeName), " +
+ "channelCounts=${channelCounts.contentToString()}, " +
+ "encodings=$encodings, " +
+ "productName=$productName, address=$addressStr)"
+ }
+
@Composable
fun DebugPage(
preferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: DebugViewModel = hiltViewModel(),
) {
- val context = LocalContext.current
- val scrollAmount = 100f
val columnState = rememberLazyListState()
val scope = rememberCoroutineScope()
+ val state by viewModel.state.collectAsState()
- fun scroll(reverse: Boolean = false) {
+ fun scroll(
+ reverse: Boolean = false,
+ scrollAmount: Float = 100f,
+ ) {
scope.launch(ExceptionHandler()) {
columnState.scrollBy(if (reverse) -scrollAmount else scrollAmount)
}
@@ -209,10 +405,12 @@ fun DebugPage(
val itemPlaybacks by viewModel.itemPlaybacks.collectAsState()
val logcat by viewModel.logcat.collectAsState()
+ val padding = remember { PaddingValues(bottom = 32.dp) }
+
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(32.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
modifier
.focusable()
@@ -230,150 +428,94 @@ fun DebugPage(
scroll(true)
return@onKeyEvent true
}
+ if (it.key == Key.MediaFastForward || it.key == Key.PageDown) {
+ scroll(false, 300f)
+ return@onKeyEvent true
+ }
+ if (it.key == Key.MediaSkipBackward || it.key == Key.PageUp) {
+ scroll(true, 300f)
+ return@onKeyEvent true
+ }
return@onKeyEvent false
},
) {
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.fillMaxWidth(),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(padding),
) {
- Text(
- text = "App Information",
- style = MaterialTheme.typography.displaySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
- val buildTime = Date(BuildConfig.BUILD_TIME)
- val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
- val installInfo =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- val installSource =
- context.packageManager.getInstallSourceInfo(context.packageName)
- buildList {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- add("Install source: ${installSource.packageSource}")
- }
- add("Installer: ${installSource.installingPackageName}")
- add("Initiator: ${installSource.initiatingPackageName}")
- }
- } else {
- listOf(context.packageManager.getInstallerPackageName(context.packageName))
- }
- (
- listOf(
- "Version Name: ${pkgInfo.versionName}",
- "Version Code: ${pkgInfo.versionCodeLong}",
- "ClientInfo: ${viewModel.clientInfo}",
- "Build type: ${BuildConfig.BUILD_TYPE}",
- "Build flavor: ${BuildConfig.FLAVOR}",
- "Build time: $buildTime",
- "FFMPEG included: ${viewModel.ffmpegIncluded}",
- "AV1 included: ${viewModel.av1Included}",
- "libmpv loaded: ${viewModel.libMpvLoaded}",
- "Debug enabled: ${BuildConfig.DEBUG}",
- "ABIs: ${Build.SUPPORTED_ABIS.toList()}",
- ) + installInfo
- ).forEach {
- Text(
- text = it.toString(),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
+ SectionTitle("App Information")
+ state.appInfo.forEach {
+ BodyText(it)
}
}
}
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.fillMaxWidth(),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(padding),
) {
- Text(
- text = "Device Information",
- style = MaterialTheme.typography.displaySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
+ SectionTitle("Device Information")
+ state.deviceInfo.forEach {
+ BodyText(it)
+ }
- listOf(
- "DeviceInfo: ${viewModel.deviceInfo}",
- "Manufacturer: ${Build.MANUFACTURER}",
- "Model: ${Build.MODEL}",
- "API Level: ${Build.VERSION.SDK_INT}",
- "Display Modes:",
- *viewModel.supportedModes,
- ).forEach {
- Text(
- text = it.toString(),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
+ SubSectionTitle("Display Modes")
+ state.displayModes.forEach {
+ BodyText(it)
+ }
+
+ SubSectionTitle("Audio Devices")
+ state.audioInfo.forEach {
+ BodyText(it)
}
}
}
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.fillMaxWidth(),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(padding),
) {
- Text(
- text = "AppPreferences",
- style = MaterialTheme.typography.displaySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = preferences.appPreferences.toString(),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
+ SectionTitle("AppPreferences")
+ BodyText(preferences.appPreferences.toString())
}
}
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.fillMaxWidth(),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(padding),
) {
- Text(
- text = "User Information",
- style = MaterialTheme.typography.displaySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = "Current server: ${viewModel.serverRepository.currentServer}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = "Current user: ${viewModel.serverRepository.currentUser}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = "User server settings: ${viewModel.serverRepository.currentUserDto?.configuration}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
+ SectionTitle("User Information")
+ BodyText("Current server: ${viewModel.serverRepository.currentServer}")
+ BodyText("Current user: ${viewModel.serverRepository.currentUser}")
+ BodyText("User server settings: ${viewModel.serverRepository.currentUserDto?.configuration}")
}
}
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.fillMaxWidth(),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(padding),
) {
- Text(
- text = "Database",
- style = MaterialTheme.typography.displaySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = "ItemPlayback",
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface,
- )
+ SectionTitle("Database")
+
+ SubSectionTitle("ItemPlayback")
itemPlaybacks.forEach {
- Text(
- text = it.toString(),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
+ BodyText(it.toString())
}
}
}
@@ -382,32 +524,55 @@ fun DebugPage(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
- Text(
- text = "Logcat",
- style = MaterialTheme.typography.displaySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
- logcat.forEach { (level, line) ->
- val color =
- when (level) {
- Log.VERBOSE -> MaterialTheme.colorScheme.onSurface
- Log.DEBUG -> Color(0xff2bc4cf)
- Log.INFO -> Color(0xff2bcf8b)
- Log.WARN -> Color(0xffdde663)
- Log.ERROR -> Color(0xffe67063)
- else -> MaterialTheme.colorScheme.onSurface
- }
- Text(
- text = line,
- style = MaterialTheme.typography.bodySmall,
- color = color,
- )
- }
+ SectionTitle("Logcat")
}
}
+ items(logcat) { (level, line) ->
+ val color =
+ when (level) {
+ Log.VERBOSE -> MaterialTheme.colorScheme.onSurface
+ Log.DEBUG -> Color(0xff2bc4cf)
+ Log.INFO -> Color(0xff2bcf8b)
+ Log.WARN -> Color(0xffdde663)
+ Log.ERROR -> Color(0xffe67063)
+ else -> MaterialTheme.colorScheme.onSurface
+ }
+ Text(
+ text = line,
+ style = MaterialTheme.typography.bodySmall,
+ color = color,
+ )
+ }
}
}
+@Composable
+private fun SectionTitle(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+}
+
+@Composable
+private fun SubSectionTitle(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+}
+
+@Composable
+private fun BodyText(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+}
+
private val THIRD_PARTY_TAGS =
listOf(
"libc:F",
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt
deleted file mode 100644
index 73ad4a2e..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt
+++ /dev/null
@@ -1,193 +0,0 @@
-package com.github.damontecres.wholphin.ui.detail
-
-import android.content.res.Resources
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowForward
-import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.ArrowForward
-import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.PlayArrow
-import androidx.compose.material.icons.filled.Refresh
-import androidx.compose.ui.graphics.Color
-import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.data.model.BaseItem
-import com.github.damontecres.wholphin.ui.components.DialogItem
-import com.github.damontecres.wholphin.ui.nav.Destination
-import com.github.damontecres.wholphin.util.supportedPlayableTypes
-import org.jellyfin.sdk.model.UUID
-import org.jellyfin.sdk.model.api.BaseItemKind
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.seconds
-
-data class MoreDialogActions(
- val navigateTo: (Destination) -> Unit,
- val onClickWatch: (UUID, Boolean) -> Unit,
- val onClickFavorite: (UUID, Boolean) -> Unit,
- val onClickAddPlaylist: (UUID) -> Unit,
- val onSendMediaInfo: (UUID) -> Unit,
- val onClickDelete: (BaseItem) -> Unit,
- val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
- val onClickRemoveFromNextUp: (BaseItem) -> Unit = {},
- val onClickAddToQueue: (BaseItem) -> Unit = {},
-)
-
-fun buildMoreDialogItemsForHome(
- resources: Resources,
- item: BaseItem,
- seriesId: UUID?,
- playbackPosition: Duration,
- watched: Boolean,
- favorite: Boolean,
- canDelete: Boolean,
- actions: MoreDialogActions,
- canRemoveContinueWatching: Boolean = false,
- canRemoveNextUp: Boolean = false,
-): List =
- buildList {
- val itemId = item.id
- add(
- DialogItem(
- resources.getString(R.string.go_to),
- Icons.Default.ArrowForward,
- ) {
- actions.onClickGoTo(item)
- },
- )
- if (item.type in supportedPlayableTypes) {
- if (playbackPosition >= 1.seconds) {
- add(
- DialogItem(
- resources.getString(R.string.resume),
- Icons.Default.PlayArrow,
- iconColor = Color.Green.copy(alpha = .8f),
- ) {
- actions.navigateTo(
- Destination.Playback(
- itemId,
- playbackPosition.inWholeMilliseconds,
- ),
- )
- },
- )
- add(
- DialogItem(
- resources.getString(R.string.restart),
- Icons.Default.Refresh,
-// iconColor = Color.Green.copy(alpha = .8f),
- ) {
- actions.navigateTo(
- Destination.Playback(
- itemId,
- 0L,
- ),
- )
- },
- )
- } else {
- add(
- DialogItem(
- resources.getString(R.string.play),
- Icons.Default.PlayArrow,
- iconColor = Color.Green.copy(alpha = .8f),
- ) {
- actions.navigateTo(
- Destination.Playback(
- itemId,
- 0L,
- ),
- )
- },
- )
- }
- }
- if (item.type == BaseItemKind.MUSIC_ALBUM) {
- add(
- DialogItem(
- resources.getString(R.string.add_to_queue),
- Icons.Default.Add,
- ) {
- actions.onClickAddToQueue(item)
- },
- )
- }
- add(
- DialogItem(
- text = R.string.add_to_playlist,
- iconStringRes = R.string.fa_list_ul,
- ) {
- actions.onClickAddPlaylist.invoke(itemId)
- },
- )
- if (canDelete) {
- add(
- DialogItem(
- resources.getString(R.string.delete),
- Icons.Default.Delete,
- iconColor = Color.Red.copy(alpha = .8f),
- ) {
- actions.onClickDelete.invoke(item)
- },
- )
- }
- if (canRemoveContinueWatching && !watched && playbackPosition > Duration.ZERO) {
- add(
- DialogItem(
- text = R.string.remove_continue_watching,
- iconStringRes = R.string.fa_eye,
- ) {
- actions.onClickWatch.invoke(itemId, false)
- },
- )
- }
- if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) {
- add(
- DialogItem(
- text = R.string.remove_next_up,
- iconStringRes = R.string.fa_tag,
- ) {
- actions.onClickRemoveFromNextUp.invoke(item)
- },
- )
- }
- add(
- DialogItem(
- text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
- iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
- ) {
- actions.onClickWatch.invoke(itemId, !watched)
- },
- )
- add(
- DialogItem(
- text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
- iconStringRes = R.string.fa_heart,
- iconColor = if (favorite) Color.Red else Color.Unspecified,
- ) {
- actions.onClickFavorite.invoke(itemId, !favorite)
- },
- )
- seriesId?.let {
- add(
- DialogItem(
- resources.getString(R.string.go_to_series),
- Icons.AutoMirrored.Filled.ArrowForward,
- ) {
- actions.navigateTo(
- Destination.MediaItem(
- it,
- BaseItemKind.SERIES,
- null,
- ),
- )
- },
- )
- }
- add(
- DialogItem(
- text = R.string.send_media_info_log_to_server,
- iconStringRes = R.string.fa_file_video,
- ) {
- actions.onSendMediaInfo.invoke(itemId)
- },
- )
- }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt
index b0124313..0e46999f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt
@@ -1,324 +1,129 @@
package com.github.damontecres.wholphin.ui.detail
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.expandVertically
-import androidx.compose.animation.shrinkVertically
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.filter.DefaultForFavoritesFilterOptions
-import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
-import com.github.damontecres.wholphin.data.model.GetItemsFilter
-import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.preferences.UserPreferences
-import com.github.damontecres.wholphin.ui.components.CollectionFolderView
+import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
+import com.github.damontecres.wholphin.ui.components.CollectionFolderViewContent
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GridClickActions
-import com.github.damontecres.wholphin.ui.components.TabRow
-import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
-import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
-import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
-import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions
-import com.github.damontecres.wholphin.ui.data.MovieSortOptions
-import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
-import com.github.damontecres.wholphin.ui.data.SortAndDirection
-import com.github.damontecres.wholphin.ui.data.VideoSortOptions
-import com.github.damontecres.wholphin.ui.logTab
-import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
-import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
+import com.github.damontecres.wholphin.ui.components.KeyedTabbedPage
+import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.components.defaultViewOptions
+import com.github.damontecres.wholphin.ui.components.rememberContextMenu
+import com.github.damontecres.wholphin.ui.data.rememberSortOptions
import com.github.damontecres.wholphin.ui.tryRequestFocus
-import org.jellyfin.sdk.model.api.BaseItemKind
-import org.jellyfin.sdk.model.api.ItemSortBy
-import org.jellyfin.sdk.model.api.SortOrder
@Composable
fun FavoritesPage(
preferences: UserPreferences,
modifier: Modifier = Modifier,
- preferencesViewModel: PreferencesViewModel = hiltViewModel(),
+ viewModel: FavoritesViewModel = hiltViewModel(),
) {
- val uiPrefs = preferences.appPreferences.interfacePreferences
- val rememberedTabIndex =
- remember {
- preferencesViewModel.getRememberedTab(
- preferences,
- NavDrawerItem.Favorites.id,
- 0,
- )
+ OneTimeLaunchedEffect { viewModel.init() }
+ val state by viewModel.state.collectAsState()
+
+ var showTabs by rememberSaveable { mutableStateOf(true) }
+
+ when (val s = state.loadingState) {
+ is FavoritesLoadingState.Error -> {
+ ErrorMessage(s.message, s.exception, modifier)
}
- val tabs =
- listOf(
- stringResource(R.string.movies),
- stringResource(R.string.tv_shows),
- stringResource(R.string.episodes),
- stringResource(R.string.videos),
- stringResource(R.string.playlists),
- stringResource(R.string.people),
- )
- val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
- var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
- val focusRequester = remember { FocusRequester() }
+ FavoritesLoadingState.Loading,
+ FavoritesLoadingState.Pending,
+ -> {
+ LoadingPage(modifier)
+ }
- LaunchedEffect(selectedTabIndex) {
- logTab("favorites", selectedTabIndex)
- preferencesViewModel.saveRememberedTab(
- preferences,
- NavDrawerItem.Favorites.id,
- selectedTabIndex,
- )
- preferencesViewModel.backdropService.clearBackdrop()
- }
- var showHeader by rememberSaveable { mutableStateOf(true) }
-
- val onClickItem = { item: BaseItem ->
- preferencesViewModel.navigationManager.navigateTo(item.destination())
- }
-
- LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
- Column(
- modifier = modifier,
- ) {
- AnimatedVisibility(
- showHeader,
- enter = expandVertically(),
- exit = shrinkVertically(),
- ) {
- TabRow(
- selectedTabIndex = selectedTabIndex,
+ FavoritesLoadingState.NoFavorites -> {
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ Box(
+ contentAlignment = Alignment.Center,
modifier =
- Modifier
- .padding(start = 32.dp, top = 16.dp, bottom = 16.dp),
- tabs = tabs,
- onClick = { selectedTabIndex = it },
- focusRequesters = tabFocusRequesters,
- )
+ modifier
+ .focusRequester(focusRequester)
+ .focusable(),
+ ) {
+ Text(
+ text = stringResource(R.string.no_favorites_found),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier,
+ )
+ }
}
- val actions =
- remember {
- GridClickActions(
- onClickItem = { _, item -> onClickItem.invoke(item) },
- )
- }
- // TODO playEnabled = true for movies & episodes
- when (selectedTabIndex) {
- // Movies
- 0 -> {
- CollectionFolderView(
- preferences = preferences,
- actions = actions,
- itemId = "${NavDrawerItem.Favorites.id}_movies",
- initialFilter =
- CollectionFolderFilter(
- filter =
- GetItemsFilter(
- favorite = true,
- includeItemTypes = listOf(BaseItemKind.MOVIE),
- ),
- ),
- showTitle = false,
- recursive = true,
- sortOptions = MovieSortOptions,
- defaultViewOptions = ViewOptionsPoster,
- modifier =
- Modifier
- .padding(start = 16.dp)
- .fillMaxSize()
- .focusRequester(focusRequester),
- positionCallback = { columns, position ->
- showHeader = position < columns
- },
- playEnabled = false,
- filterOptions = DefaultForFavoritesFilterOptions,
- focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
- )
- }
+ FavoritesLoadingState.Success -> {
+// LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ KeyedTabbedPage(
+ selectedTabKey = state.tabKey,
+ updateSelectedTabKey = viewModel::updateSelectedTabKey,
+ tabs = state.tabs,
+ showTabs = showTabs,
+ isShowClock = state.isShowClock,
+ modifier = modifier,
+ ) { type, tabDetails ->
+ val collectionState = state.favorites[type]
+ if (collectionState != null) {
+ val provider = remember(type) { viewModel.createTypedProvider(type) }
+ val contextMenu = rememberContextMenu(preferences, provider)
+ val actions =
+ remember(type) {
+ GridClickActions(
+ onClickItem = { _, item ->
+ viewModel.navigationManager.navigateTo(
+ item.destination(),
+ )
+ },
+ onLongClickItem = contextMenu::showContextMenu,
+ )
+ }
+ CollectionFolderViewContent(
+ preferences = preferences,
+ state = collectionState,
+ savedPosition = 0,
+ itemId = remember { viewModel.libraryDisplayItemId(type) },
+ initialFilter = CollectionFolderFilter(),
+ recursive = true,
+ actions = actions,
+ sortOptions = rememberSortOptions(type),
+ // TODO playEnabled = true for movies & episodes
+ playEnabled = false,
+ defaultViewOptions = type.defaultViewOptions,
+ viewActions = provider,
+ provider = provider,
+ showTitle = false,
+ positionCallback = { columns, index ->
+ showTabs = index < columns
+ },
+ focusRequesterOnEmpty = null,
+ filterOptions = DefaultForFavoritesFilterOptions,
+ )
- // TV
- 1 -> {
- CollectionFolderView(
- preferences = preferences,
- actions = actions,
- itemId = "${NavDrawerItem.Favorites.id}_series",
- initialFilter =
- CollectionFolderFilter(
- filter =
- GetItemsFilter(
- favorite = true,
- includeItemTypes = listOf(BaseItemKind.SERIES),
- ),
- ),
- showTitle = false,
- recursive = true,
- sortOptions = SeriesSortOptions,
- defaultViewOptions = ViewOptionsPoster,
- modifier =
- Modifier
- .padding(start = 16.dp)
- .fillMaxSize()
- .focusRequester(focusRequester),
- positionCallback = { columns, position ->
- showHeader = position < columns
- },
- playEnabled = false,
- filterOptions = DefaultForFavoritesFilterOptions,
- focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
- )
- }
-
- // Episodes
- 2 -> {
- CollectionFolderView(
- preferences = preferences,
- actions = actions,
- itemId = "${NavDrawerItem.Favorites.id}_episodes",
- initialFilter =
- CollectionFolderFilter(
- filter =
- GetItemsFilter(
- favorite = true,
- includeItemTypes = listOf(BaseItemKind.EPISODE),
- ),
- ),
- showTitle = false,
- recursive = true,
- sortOptions = EpisodeSortOptions,
- defaultViewOptions = ViewOptionsWide,
- useSeriesForPrimary = false,
- modifier =
- Modifier
- .padding(start = 16.dp)
- .fillMaxSize()
- .focusRequester(focusRequester),
- positionCallback = { columns, position ->
- showHeader = position < columns
- },
- playEnabled = false,
- filterOptions = DefaultForFavoritesFilterOptions,
- focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
- )
- }
-
- // Videos
- 3 -> {
- CollectionFolderView(
- preferences = preferences,
- actions = actions,
- itemId = "${NavDrawerItem.Favorites.id}_videos",
- initialFilter =
- CollectionFolderFilter(
- filter =
- GetItemsFilter(
- favorite = true,
- includeItemTypes = listOf(BaseItemKind.VIDEO),
- ),
- ),
- showTitle = false,
- recursive = true,
- sortOptions = VideoSortOptions,
- defaultViewOptions = ViewOptionsWide,
- modifier =
- Modifier
- .padding(start = 16.dp)
- .fillMaxSize()
- .focusRequester(focusRequester),
- positionCallback = { columns, position ->
- showHeader = position < columns
- },
- playEnabled = false,
- filterOptions = DefaultForFavoritesFilterOptions,
- focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
- )
- }
-
- // Playlists
- 4 -> {
- CollectionFolderView(
- preferences = preferences,
- actions = actions,
- itemId = "${NavDrawerItem.Favorites.id}_playlists",
- initialFilter =
- CollectionFolderFilter(
- filter =
- GetItemsFilter(
- favorite = true,
- includeItemTypes = listOf(BaseItemKind.PLAYLIST),
- ),
- ),
- showTitle = false,
- recursive = true,
- sortOptions = VideoSortOptions,
- defaultViewOptions = ViewOptionsSquare,
- modifier =
- Modifier
- .padding(start = 16.dp)
- .fillMaxSize()
- .focusRequester(focusRequester),
- positionCallback = { columns, position ->
- showHeader = position < columns
- },
- playEnabled = false,
- filterOptions = DefaultForFavoritesFilterOptions,
- focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
- )
- }
-
- // People
- 5 -> {
- CollectionFolderView(
- preferences = preferences,
- actions = actions,
- itemId = "${NavDrawerItem.Favorites.id}_people",
- initialFilter =
- CollectionFolderFilter(
- filter =
- GetItemsFilter(
- favorite = true,
- override = GetItemsFilterOverride.PERSON,
- ),
- ),
- initialSortAndDirection =
- SortAndDirection(
- ItemSortBy.DEFAULT,
- SortOrder.ASCENDING,
- ),
- showTitle = false,
- recursive = true,
- sortOptions = listOf(),
- defaultViewOptions = ViewOptionsPoster,
- modifier =
- Modifier
- .padding(start = 16.dp)
- .fillMaxSize()
- .focusRequester(focusRequester),
- positionCallback = { columns, position ->
- showHeader = position < columns
- },
- playEnabled = false,
- filterOptions = listOf(),
- focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
- )
- }
-
- else -> {
- ErrorMessage("Invalid tab index $selectedTabIndex", null)
+ contextMenu.Compose()
+ } else {
+ ErrorMessage("Invalid tab $type", null)
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt
new file mode 100644
index 00000000..adbd9a44
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt
@@ -0,0 +1,798 @@
+package com.github.damontecres.wholphin.ui.detail
+
+import android.content.Context
+import androidx.annotation.VisibleForTesting
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.snapshots.SnapshotStateMap
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.filter.FilterValueOption
+import com.github.damontecres.wholphin.data.filter.ItemFilterBy
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.GetItemsFilter
+import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.services.BackdropService
+import com.github.damontecres.wholphin.services.FavoriteWatchManager
+import com.github.damontecres.wholphin.services.FilterOptionCache
+import com.github.damontecres.wholphin.services.MediaManagementService
+import com.github.damontecres.wholphin.services.NavDrawerService
+import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.RememberedTabService
+import com.github.damontecres.wholphin.services.ServerReportService
+import com.github.damontecres.wholphin.services.StreamChoiceService
+import com.github.damontecres.wholphin.services.UserPreferencesService
+import com.github.damontecres.wholphin.services.deleteItem
+import com.github.damontecres.wholphin.ui.SlimItemFields
+import com.github.damontecres.wholphin.ui.collectLatestIn
+import com.github.damontecres.wholphin.ui.components.CollectionFolderState
+import com.github.damontecres.wholphin.ui.components.CollectionFolderViewActions
+import com.github.damontecres.wholphin.ui.components.ContextMenuProvider
+import com.github.damontecres.wholphin.ui.components.TabDetails
+import com.github.damontecres.wholphin.ui.components.ViewOptions
+import com.github.damontecres.wholphin.ui.components.baseItemKinds
+import com.github.damontecres.wholphin.ui.components.defaultViewOptions
+import com.github.damontecres.wholphin.ui.data.SortAndDirection
+import com.github.damontecres.wholphin.ui.equalsNotNull
+import com.github.damontecres.wholphin.ui.formatTypeName
+import com.github.damontecres.wholphin.ui.launchDefault
+import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.main.settings.Library
+import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
+import com.github.damontecres.wholphin.ui.showToast
+import com.github.damontecres.wholphin.util.ApiRequestPager
+import com.github.damontecres.wholphin.util.BlockingList
+import com.github.damontecres.wholphin.util.DataLoadingState
+import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.GetArtistsHandler
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import com.github.damontecres.wholphin.util.GetPersonsHandler
+import com.github.damontecres.wholphin.util.LoadingState
+import com.github.damontecres.wholphin.util.WholphinDispatchers
+import com.github.damontecres.wholphin.util.successValue
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.request.GetArtistsRequest
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import org.jellyfin.sdk.model.api.request.GetPersonsRequest
+import timber.log.Timber
+import java.util.SortedMap
+import java.util.TreeMap
+import java.util.UUID
+import javax.inject.Inject
+
+@HiltViewModel
+class FavoritesViewModel
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
+ val navigationManager: NavigationManager,
+ private val serverRepository: ServerRepository,
+ private val navDrawerService: NavDrawerService,
+ private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
+ private val favoriteWatchManager: FavoriteWatchManager,
+ private val backdropService: BackdropService,
+ private val userPreferencesService: UserPreferencesService,
+ private val mediaManagementService: MediaManagementService,
+ val streamChoiceService: StreamChoiceService,
+ val serverReportService: ServerReportService,
+ private val filterOptionCache: FilterOptionCache,
+ private val rememberedTabService: RememberedTabService,
+ ) : ViewModel() {
+ private val _state = MutableStateFlow(FavoritesPageState())
+ val state: StateFlow = _state
+
+ fun init() {
+ viewModelScope.launchIO {
+ val rememberTabs =
+ userPreferencesService
+ .getCurrent()
+ .appPreferences.interfacePreferences.rememberSelectedTab
+
+ setupPossibleTypes()
+
+ val favoriteTypeOptions = state.value.favoriteTypeOptions
+ if (favoriteTypeOptions.isNotEmpty()) {
+ val tabKey =
+ if (rememberTabs) {
+ val rememberedTab =
+ rememberedTabService
+ .getRememberedTab(NavDrawerItem.Favorites.id)
+ ?.let {
+ BaseItemKind.entries[it]
+ }
+ if (rememberedTab != null && rememberedTab in favoriteTypeOptions) {
+ rememberedTab
+ } else {
+ favoriteTypeOptions.first()
+ }
+ } else {
+ favoriteTypeOptions.first()
+ }
+ val jobs =
+ favoriteTypeOptions.associateWith { type -> initialFetchForType(type) }
+ initialFetchAndWait(tabKey, jobs)
+ } else {
+ Timber.w("No available types to check for favorites!")
+ _state.update {
+ it.copy(loadingState = FavoritesLoadingState.NoFavorites)
+ }
+ }
+
+ userPreferencesService.flow
+ .map { it.appPreferences.interfacePreferences.showClock }
+ .collectLatestIn(viewModelScope) { isShowClock ->
+ _state.update { it.copy(isShowClock = isShowClock) }
+ }
+ }
+ }
+
+ @VisibleForTesting
+ internal fun setupPossibleTypes() {
+ val availableTypes =
+ navDrawerService.state.value.allLibraries
+ .possibleFavoriteTypes(false)
+ .toSet()
+ val favoriteTypeOptions = favoriteOptions.filter { it in availableTypes }
+ _state.update {
+ it.copy(
+ favoriteTypeOptions = favoriteTypeOptions,
+ )
+ }
+ }
+
+ /**
+ * Starts queries for favorites, waiting for [firstTabKey] to complete
+ *
+ * Exposed for testing
+ */
+ internal suspend fun initialFetchAndWait(
+ firstTabKey: BaseItemKind,
+ jobs: Map>>>,
+ ) {
+ val completed =
+ jobs[firstTabKey]?.let {
+ when (val result = it.await()) {
+ DataLoadingState.Loading,
+ DataLoadingState.Pending,
+ -> false
+
+ is DataLoadingState.Error,
+ -> true
+
+ is DataLoadingState.Success> -> result.data.isNotEmpty()
+ }
+ } == true
+ if (completed) {
+ Timber.v("Got completed for %s", firstTabKey)
+ _state.update {
+ it.copy(
+ loadingState = FavoritesLoadingState.Success,
+ tabKey = firstTabKey,
+ )
+ }
+ } else {
+ // If the first key wasn't successful, wait in order until first successful & non-empty
+ val favoriteTypeOptions = state.value.favoriteTypeOptions
+ val firstCompleted =
+ favoriteTypeOptions.firstOrNull { type ->
+ val job = jobs[type] ?: return@firstOrNull false
+ val result = job.await()
+ result is DataLoadingState.Error ||
+ (result is DataLoadingState.Success> && result.data.isNotEmpty())
+ }
+ _state.update {
+ if (firstCompleted != null) {
+ it.copy(
+ loadingState = FavoritesLoadingState.Success,
+ tabKey = firstCompleted,
+ )
+ } else {
+ // None were error or successful & non-empty
+ // Check if they were all empty which means the user has no favorites at all
+ val allEmpty =
+ jobs.all { (_, job) ->
+ val result = job.await()
+ result is DataLoadingState.Success> && result.data.isEmpty()
+ }
+ if (allEmpty) {
+ it.copy(
+ loadingState = FavoritesLoadingState.NoFavorites,
+ )
+ } else {
+ // This means a least one failed
+ val firstError =
+ jobs.entries.firstOrNull { (_, job) ->
+ job.await() is DataLoadingState.Error
+ }
+ if (firstError != null) {
+ it.copy(
+ loadingState = FavoritesLoadingState.Success,
+ tabKey = firstError.key,
+ )
+ } else {
+ // This shouldn't happen
+ it.copy(
+ loadingState = FavoritesLoadingState.Error("An unknown error occurred"),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Do the initial fetch for a [BaseItemKind] which will look up any existing saved sort and filter
+ */
+ private fun initialFetchForType(type: BaseItemKind): Deferred>> =
+ viewModelScope.async(WholphinDispatchers.IO) {
+ try {
+ val collectionState =
+ CollectionFolderState(
+ item = DataLoadingState.Loading,
+ items = DataLoadingState.Loading,
+ backgroundLoading = LoadingState.Loading,
+ viewOptions = ViewOptions(),
+ )
+ _state.value.favorites[type] = collectionState
+
+ val libraryDisplayInfo =
+ serverRepository.currentUser?.let { user ->
+ libraryDisplayInfoDao.getItem(user, libraryDisplayItemId(type))
+ }
+ val sortAndDirection =
+ libraryDisplayInfo?.sortAndDirection ?: SortAndDirection.DEFAULT
+ val filter = libraryDisplayInfo?.filter ?: GetItemsFilter(favorite = true)
+ val viewOptions = libraryDisplayInfo?.viewOptions ?: type.defaultViewOptions
+ fetchType(type, sortAndDirection, filter, viewOptions)
+ } catch (ex: CancellationException) {
+ throw ex
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error fetching favorites for %s", type)
+ updateForException(type, ex)
+ DataLoadingState.Error(ex)
+ }
+ }
+
+ /**
+ * Query for favorites of the specified [BaseItemKind] using the specified sort and filter
+ */
+ @VisibleForTesting
+ internal suspend fun fetchType(
+ type: BaseItemKind,
+ sortAndDirection: SortAndDirection,
+ filter: GetItemsFilter,
+ viewOptions: ViewOptions,
+ ): DataLoadingState> {
+ val collectionState =
+ CollectionFolderState(
+ item = DataLoadingState.Loading,
+ items = DataLoadingState.Loading,
+ backgroundLoading = LoadingState.Loading,
+ viewOptions = ViewOptions(),
+ )
+ _state.value.favorites[type] = collectionState
+ try {
+ val pager = createPager(type, filter, sortAndDirection)
+ _state.value.favorites[type] =
+ collectionState.copy(
+ item = DataLoadingState.Success(null),
+ items = DataLoadingState.Success(pager),
+ backgroundLoading = LoadingState.Success,
+ sortAndDirection = sortAndDirection,
+ filter = filter,
+ viewOptions = viewOptions,
+ )
+ Timber.v("Got %s favorites for %s", pager.size, type)
+ _state.update {
+ val newTabs =
+ it.createMap {
+ if (pager.isNotEmpty()) {
+ put(type, TabDetails(formatTypeName(type)))
+ } else {
+ remove(type)
+ }
+ }
+ it.copy(
+ tabs = newTabs,
+ )
+ }
+ return DataLoadingState.Success(pager)
+ } catch (ex: CancellationException) {
+ throw ex
+ } catch (ex: Exception) {
+ updateForException(type, ex)
+ return DataLoadingState.Error(ex)
+ }
+ }
+
+ /**
+ * If fetching data for a type fails, this should be called to update the state with the error
+ */
+ private fun updateForException(
+ type: BaseItemKind,
+ ex: Exception,
+ ) {
+ _state.update {
+ // Want to store the error for the tab
+ _state.value.favorites[type] =
+ CollectionFolderState(
+ item = DataLoadingState.Success(null),
+ items = DataLoadingState.Error(ex),
+ backgroundLoading = LoadingState.Success,
+ viewOptions = type.defaultViewOptions,
+ )
+ // And make sure the tab is included
+ val newTabs =
+ it.createMap {
+ put(type, TabDetails(formatTypeName(type)))
+ }
+ it.copy(tabs = newTabs)
+ }
+ }
+
+ private fun createGetItemsRequest(
+ type: BaseItemKind,
+ filter: GetItemsFilter,
+ sortAndDirection: SortAndDirection,
+ ): GetItemsRequest =
+ filter.applyTo(
+ GetItemsRequest(
+ isFavorite = true,
+ recursive = true,
+ includeItemTypes = listOf(type),
+ fields = SlimItemFields,
+ sortBy = listOf(sortAndDirection.sort),
+ sortOrder = listOf(sortAndDirection.direction),
+ ),
+ overwriteIncludeTypes = false,
+ )
+
+ private fun createGetArtistsRequest(
+ filter: GetItemsFilter,
+ sortAndDirection: SortAndDirection,
+ ): GetArtistsRequest =
+ filter.applyTo(
+ GetArtistsRequest(
+ isFavorite = true,
+ fields = SlimItemFields,
+ sortBy = listOf(sortAndDirection.sort),
+ sortOrder = listOf(sortAndDirection.direction),
+ ),
+ )
+
+ private fun createGetPersonsRequest(filter: GetItemsFilter): GetPersonsRequest =
+ filter.applyTo(
+ GetPersonsRequest(
+ isFavorite = true,
+ fields = SlimItemFields,
+ ),
+ )
+
+ private suspend fun createPager(
+ type: BaseItemKind,
+ filter: GetItemsFilter,
+ sortAndDirection: SortAndDirection,
+ ): ApiRequestPager<*> =
+ when (type) {
+ BaseItemKind.MUSIC_ARTIST -> {
+ val request = createGetArtistsRequest(filter, sortAndDirection)
+ ApiRequestPager(api, request, GetArtistsHandler, viewModelScope, pageSize = 50)
+ }
+
+ BaseItemKind.PERSON -> {
+ val request = createGetPersonsRequest(filter)
+ ApiRequestPager(api, request, GetPersonsHandler, viewModelScope, pageSize = 50)
+ }
+
+ else -> {
+ val request = createGetItemsRequest(type, filter, sortAndDirection)
+ ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope, pageSize = 50)
+ }
+ }.init()
+
+ fun libraryDisplayItemId(type: BaseItemKind): String {
+ // Previous version used hardcoded keys before more types were supported
+ // So override those type names so that user's settings are maintained
+ val typeKey =
+ when (type) {
+ BaseItemKind.MOVIE -> "movies"
+ BaseItemKind.SERIES -> "series"
+ BaseItemKind.EPISODE -> "episodes"
+ BaseItemKind.VIDEO -> "videos"
+ BaseItemKind.PLAYLIST -> "playlists"
+ BaseItemKind.PERSON -> "people"
+ else -> type.serialName
+ }
+ return "${NavDrawerItem.Favorites.id}_$typeKey"
+ }
+
+ private fun collectionStateFor(type: BaseItemKind): CollectionFolderState? = state.value.favorites[type]
+
+ fun updateSelectedTabKey(newKey: BaseItemKind) {
+ viewModelScope.launchDefault { backdropService.clearBackdrop() }
+ viewModelScope.launchIO {
+ val rememberTabs =
+ userPreferencesService
+ .getCurrent()
+ .appPreferences.interfacePreferences.rememberSelectedTab
+ if (rememberTabs) {
+ rememberedTabService.saveRememberedTab(
+ NavDrawerItem.Favorites.id,
+ newKey.ordinal,
+ )
+ }
+ }
+ _state.update { it.copy(tabKey = newKey) }
+ }
+
+ private suspend fun refreshAfterMutate(
+ type: BaseItemKind,
+ position: Int,
+ itemId: UUID,
+ ) {
+ try {
+ val pager =
+ collectionStateFor(type)?.let {
+ ((it.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
+ }
+ pager?.refreshItem(position, itemId)
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error refreshing after mutate %s", itemId)
+ showToast(context, "Error refreshing after item mutate")
+ }
+ }
+
+ private suspend fun refreshAfterDelete(
+ type: BaseItemKind,
+ position: Int,
+ deletedItem: BaseItem,
+ ) {
+ try {
+ val pager =
+ collectionStateFor(type)?.let {
+ ((it.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
+ }
+
+ position.let {
+ Timber.v("Item deleted: position=%s, id=%s", it, deletedItem.id)
+ val item = pager?.get(it)
+ // Exact item deleted (eg a movie) or deleted item was within the series
+ if (item?.id == deletedItem.id ||
+ equalsNotNull(item?.data?.id, deletedItem.data.seriesId)
+ ) {
+ pager?.refreshPagesAfter(position)
+ }
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error refreshing after deleted item %s", deletedItem.id)
+ showToast(context, "Error refreshing after item deleted")
+ }
+ }
+
+ private fun saveLibraryDisplayInfo(
+ type: BaseItemKind,
+ newFilter: GetItemsFilter,
+ newSort: SortAndDirection,
+ viewOptions: ViewOptions?,
+ ) {
+ serverRepository.currentUser?.let { user ->
+ viewModelScope.launchIO {
+ val libraryDisplayInfo =
+ LibraryDisplayInfo(
+ userId = user.rowId,
+ itemId = libraryDisplayItemId(type),
+ sort = newSort.sort,
+ direction = newSort.direction,
+ filter = newFilter,
+ viewOptions = viewOptions,
+ )
+ libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
+ }
+ }
+ }
+
+ private fun saveViewOptions(
+ type: BaseItemKind,
+ viewOptions: ViewOptions,
+ ) {
+ viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
+ collectionStateFor(type)?.let { collectionState ->
+ saveLibraryDisplayInfo(
+ type = type,
+ newFilter = collectionState.filter,
+ newSort = collectionState.sortAndDirection,
+ viewOptions = viewOptions,
+ )
+ state.value.favorites[type] =
+ collectionState.copy(
+ viewOptions = viewOptions,
+ )
+ if (!viewOptions.showBackdrop) {
+ backdropService.clearBackdrop()
+ }
+ }
+ }
+ }
+
+ fun createTypedProvider(type: BaseItemKind) = TypedProvider(type)
+
+ /**
+ * This provides context menu actions and other actions on a per [BaseItemKind] basis
+ */
+ @Immutable
+ inner class TypedProvider(
+ val type: BaseItemKind,
+ ) : ContextMenuProvider,
+ CollectionFolderViewActions {
+ override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true
+
+ override fun navigateTo(destination: Destination) = navigationManager.navigateTo(destination)
+
+ override fun canDelete(
+ item: BaseItem,
+ appPreferences: AppPreferences,
+ ): Boolean = mediaManagementService.canDelete(item, appPreferences)
+
+ override fun deleteItem(
+ index: Int,
+ item: BaseItem,
+ ) {
+ deleteItem(context, mediaManagementService, item) {
+ viewModelScope.launchDefault {
+ refreshAfterDelete(type, index, item)
+ }
+ }
+ }
+
+ override fun setWatched(
+ position: Int,
+ itemId: UUID,
+ played: Boolean,
+ ) {
+ viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
+ favoriteWatchManager.setWatched(itemId, played)
+ refreshAfterMutate(type, position, itemId)
+ }
+ }
+
+ override fun setFavorite(
+ position: Int,
+ itemId: UUID,
+ favorite: Boolean,
+ ) {
+ viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
+ favoriteWatchManager.setWatched(itemId, favorite)
+ collectionStateFor(type)?.let { collectionState ->
+ // TODO use pager.refreshPagesAfter ??
+ fetchType(
+ type,
+ collectionState.sortAndDirection,
+ collectionState.filter,
+ collectionState.viewOptions,
+ )
+ }
+ }
+ }
+
+ override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId)
+
+ override fun updateBackdrop(item: BaseItem) {
+ viewModelScope.launchIO {
+ backdropService.submit(item)
+ }
+ }
+
+ override fun onSortChange(
+ sortAndDirection: SortAndDirection,
+ recursive: Boolean,
+ filter: GetItemsFilter,
+ ) {
+ viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
+ Timber.v("onSortChange: type=%s, sortAndDirection=%s", type, sortAndDirection)
+ collectionStateFor(type)?.let { collectionState ->
+ saveLibraryDisplayInfo(
+ type = type,
+ newFilter = filter,
+ newSort = sortAndDirection,
+ viewOptions = collectionState.viewOptions,
+ )
+ state.value.favorites[type] =
+ collectionState.copy(
+ filter = filter,
+ sortAndDirection = sortAndDirection,
+ )
+ fetchType(type, sortAndDirection, filter, collectionState.viewOptions)
+ }
+ }
+ }
+
+ override fun onFilterChange(
+ newFilter: GetItemsFilter,
+ recursive: Boolean,
+ ) {
+ viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
+ Timber.v("onFilterChange: type=%s, newFilter=%s", type, newFilter)
+ collectionStateFor(type)?.let { collectionState ->
+ saveLibraryDisplayInfo(
+ type = type,
+ newFilter = newFilter,
+ newSort = collectionState.sortAndDirection,
+ viewOptions = collectionState.viewOptions,
+ )
+ state.value.favorites[type] =
+ collectionState.copy(
+ filter = newFilter,
+ )
+ fetchType(
+ type,
+ collectionState.sortAndDirection,
+ newFilter,
+ collectionState.viewOptions,
+ )
+ }
+ }
+ }
+
+ override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List =
+ // TODO use includeItemTypes?
+ filterOptionCache.getFilterOptionValues(
+ null,
+ filterOption,
+ )
+
+ override suspend fun positionOfLetter(letter: Char): Int? =
+ collectionStateFor(type)?.let { collectionState ->
+ when (type) {
+ BaseItemKind.MUSIC_ARTIST -> {
+ val request =
+ createGetArtistsRequest(
+ collectionState.filter,
+ collectionState.sortAndDirection,
+ ).copy(
+ enableUserData = false,
+ limit = 0,
+ enableTotalRecordCount = true,
+ )
+ GetArtistsHandler.execute(api, request).content.totalRecordCount
+ }
+
+ BaseItemKind.PERSON -> {
+ null
+ }
+
+ else -> {
+ val request =
+ createGetItemsRequest(
+ type,
+ collectionState.filter,
+ collectionState.sortAndDirection,
+ ).copy(
+ enableUserData = false,
+ limit = 0,
+ enableTotalRecordCount = true,
+ )
+ GetItemsRequestHandler.execute(api, request).content.totalRecordCount
+ }
+ }
+ }
+
+ override fun saveViewOptions(viewOptions: ViewOptions) {
+ saveViewOptions(type, viewOptions)
+ }
+
+ override fun onClickRandom() {
+ viewModelScope.launchIO {
+ try {
+ collectionStateFor(type)?.let { collectionState ->
+ val random =
+ (collectionState.items.successValue as? BlockingList)?.randomBlocking()
+ Timber.d("Got random item: %s", random?.id)
+ random?.destination()?.let {
+ navigateTo(random.destination())
+ }
+ }
+ } catch (ex: CancellationException) {
+ throw ex
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error getting random")
+ showToast(context, "Error: ${ex.localizedMessage}}")
+ }
+ }
+ }
+ }
+ }
+
+data class FavoritesPageState(
+ val loadingState: FavoritesLoadingState = FavoritesLoadingState.Pending,
+ val favoriteTypeOptions: List = emptyList(),
+ val favorites: SnapshotStateMap = SnapshotStateMap(),
+ val tabs: SortedMap = TreeMap(compareBy { favoriteOptions.indexOf(it) }),
+ val tabKey: BaseItemKind = favoriteOptions.first(),
+ val isShowClock: Boolean = true,
+)
+
+sealed interface FavoritesLoadingState {
+ data object Pending : FavoritesLoadingState
+
+ data object Loading : FavoritesLoadingState
+
+ data object Success : FavoritesLoadingState
+
+ data object NoFavorites : FavoritesLoadingState
+
+ data class Error(
+ val message: String? = null,
+ val exception: Throwable? = null,
+ ) : FavoritesLoadingState {
+ constructor(exception: Throwable) : this(null, exception)
+
+ val localizedMessage: String =
+ listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
+ }
+}
+
+/**
+ * List of supported favorite types
+ *
+ * This is also the order they will appear in the UI
+ */
+val favoriteOptions =
+ listOf(
+ BaseItemKind.MOVIE,
+ BaseItemKind.SERIES,
+ BaseItemKind.EPISODE,
+ BaseItemKind.BOX_SET,
+ BaseItemKind.PERSON,
+ BaseItemKind.TV_CHANNEL,
+ BaseItemKind.MUSIC_ALBUM,
+ BaseItemKind.MUSIC_ARTIST,
+ BaseItemKind.AUDIO,
+ BaseItemKind.MUSIC_VIDEO,
+ BaseItemKind.PLAYLIST,
+ BaseItemKind.VIDEO,
+ BaseItemKind.PHOTO,
+ BaseItemKind.PHOTO_ALBUM,
+ )
+
+/**
+ * Creates a new tab map from the current state applying the supplied block which can contain mutations
+ */
+private inline fun FavoritesPageState.createMap(block: TreeMap.() -> Unit) =
+ TreeMap(compareBy { favoriteOptions.indexOf(it) })
+ .apply {
+ putAll(this@createMap.tabs)
+ block.invoke(this)
+ }
+
+/**
+ * Given the list of libraries, return the possible favorite types
+ */
+fun List.possibleFavoriteTypes(sort: Boolean) =
+ flatMap { it.collectionType.baseItemKinds }
+ .distinct()
+ .filter { it in favoriteOptions }
+ .let {
+ // No library for persons, so always include it
+ val list = it + listOf(BaseItemKind.PERSON)
+ if (sort) {
+ list.sortedWith(compareBy { favoriteOptions.indexOf(it) })
+ } else {
+ list
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt
index e2288281..1fd7ea9f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt
@@ -10,17 +10,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig
@@ -30,50 +26,44 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.MediaManagementService
-import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.ui.cards.GridCard
-import com.github.damontecres.wholphin.ui.components.ContextMenu
-import com.github.damontecres.wholphin.ui.components.ContextMenuActions
-import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
+import com.github.damontecres.wholphin.ui.components.ContextMenuProvider
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.GridTitle
import com.github.damontecres.wholphin.ui.components.LoadingPage
-import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.StudioCardGrid
-import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
-import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
-import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
+import com.github.damontecres.wholphin.ui.components.rememberContextMenu
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.scale
-import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.ui.util.StringStringProvider
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import org.jellyfin.sdk.model.api.MediaType
+import org.jellyfin.sdk.model.api.CollectionType
import timber.log.Timber
import java.util.UUID
@@ -93,10 +83,11 @@ class HomeRowGridViewModel
private val mediaManagementService: MediaManagementService,
private val musicService: MusicService,
val streamChoiceService: StreamChoiceService,
- val mediaReportService: MediaReportService,
+ val serverReportService: ServerReportService,
@Assisted private val title: StringProvider,
@Assisted private val rowConfig: HomeRowConfig,
- ) : ViewModel() {
+ ) : ViewModel(),
+ ContextMenuProvider {
@AssistedFactory
interface Factory {
fun create(
@@ -170,44 +161,52 @@ class HomeRowGridViewModel
}
}
- fun setWatched(
+ override fun setWatched(
position: Int,
itemId: UUID,
played: Boolean,
- ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
- favoriteWatchManager.setWatched(itemId, played)
- (state.value.loading as? HomeRowLoadingState.Success)?.let {
- (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId)
+ ) {
+ viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
+ favoriteWatchManager.setWatched(itemId, played)
+ (state.value.loading as? HomeRowLoadingState.Success)?.let {
+ (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId)
+ }
}
}
- fun setFavorite(
+ override fun setFavorite(
position: Int,
itemId: UUID,
favorite: Boolean,
- ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
- favoriteWatchManager.setFavorite(itemId, favorite)
- (state.value.loading as? HomeRowLoadingState.Success)?.let {
- (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId)
+ ) {
+ viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
+ favoriteWatchManager.setFavorite(itemId, favorite)
+ (state.value.loading as? HomeRowLoadingState.Success)?.let {
+ (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId)
+ }
}
}
+ override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId)
+
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
- fun navigateTo(destination: Destination) {
+ override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true
+
+ override fun navigateTo(destination: Destination) {
navigationManager.navigateTo(destination)
}
- fun canDelete(
+ override fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
- fun deleteItem(
+ override fun deleteItem(
index: Int,
item: BaseItem,
) {
@@ -233,47 +232,12 @@ fun HomeRowGrid(
hiltViewModel(
creationCallback = { it.create(destination.title, destination.config) },
),
- playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
- var position by rememberInt(destination.initialPosition)
+ val contextMenu = rememberContextMenu(preferences, viewModel)
val gridFocusRequester = remember { FocusRequester() }
val viewOptions = destination.config.viewOptions
- var showContextMenu by remember { mutableStateOf(null) }
- var overviewDialog by remember { mutableStateOf(null) }
- var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
- val playlistState by playlistViewModel.playlistState.collectAsState()
-
- val contextActions =
- remember {
- ContextMenuActions(
- navigateTo = viewModel::navigateTo,
- onClickWatch = { itemId, watched ->
- viewModel.setWatched(position, itemId, watched)
- },
- onClickFavorite = { itemId, favorite ->
- viewModel.setFavorite(position, itemId, favorite)
- },
- onClickAddPlaylist = { itemId ->
- playlistViewModel.loadPlaylists(MediaType.VIDEO)
- showPlaylistDialog.makePresent(itemId)
- },
- onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
- onDeleteItem = { viewModel.deleteItem(position, it) },
- onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
- onChooseVersion = { _, _ ->
- // Not supported on this page
- },
- onChooseTracks = { result ->
- // Not supported on this page
- },
- onClearChosenStreams = {
- // Not supported on this page
- },
- )
- }
-
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
@@ -299,6 +263,7 @@ fun HomeRowGrid(
includeItemTypes = null,
modifier = Modifier.fillMaxSize(),
initialPosition = destination.initialPosition,
+ collectionType = CollectionType.UNKNOWN,
)
}
@@ -318,32 +283,11 @@ fun HomeRowGrid(
viewModel.navigateTo(item.destination(index))
}
}
- val onLongClickItem =
- remember {
- { index: Int, item: BaseItem ->
- showContextMenu =
- ContextMenu.ForBaseItem(
- fromLongClick = true,
- item = item,
- chosenStreams = null,
- showGoTo = true,
- showStreamChoices = false,
- canDelete =
- viewModel.canDelete(
- item,
- preferences.appPreferences,
- ),
- canRemoveContinueWatching = false,
- canRemoveNextUp = false, // TODO
- actions = contextActions,
- )
- }
- }
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
CardGrid(
pager = st.items,
onClickItem = onClickItem,
- onLongClickItem = onLongClickItem,
+ onLongClickItem = contextMenu::showContextMenu,
onClickPlay = { _, _ -> },
letterPosition = { -1 },
gridFocusRequester = gridFocusRequester,
@@ -352,7 +296,6 @@ fun HomeRowGrid(
modifier = Modifier.fillMaxSize(),
initialPosition = destination.initialPosition,
positionCallback = { columns, newPosition ->
- position = newPosition
},
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
@@ -376,39 +319,5 @@ fun HomeRowGrid(
}
}
}
- overviewDialog?.let { info ->
- ItemDetailsDialog(
- info = info,
- showFilePath =
- viewModel.serverRepository.currentUserDto
- ?.policy
- ?.isAdministrator == true,
- onDismissRequest = { overviewDialog = null },
- )
- }
- showContextMenu?.let { contextMenu ->
- ContextMenuDialog(
- onDismissRequest = { showContextMenu = null },
- getMediaSource = null,
- contextMenu = contextMenu,
- preferredSubtitleLanguage = null,
- )
- }
- showPlaylistDialog.compose { itemId ->
- PlaylistDialog(
- title = stringResource(R.string.add_to_playlist),
- state = playlistState,
- onDismissRequest = { showPlaylistDialog.makeAbsent() },
- onClick = {
- playlistViewModel.addToPlaylist(it.id, itemId)
- showPlaylistDialog.makeAbsent()
- },
- createEnabled = true,
- onCreatePlaylist = {
- playlistViewModel.createPlaylistAndAddItem(it, itemId)
- showPlaylistDialog.makeAbsent()
- },
- elevation = 3.dp,
- )
- }
+ contextMenu.Compose()
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt
index 0efb9ae8..27212b5e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt
@@ -28,6 +28,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -45,10 +46,11 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService
+import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
+import com.github.damontecres.wholphin.ui.ItemRowFields
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.PreviewTvSpec
-import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
@@ -155,6 +157,7 @@ class PersonViewModel
_state.update {
it.copy(
discovered = results,
+ discoverPerson = seerrService.discoverPerson(person),
)
}
}
@@ -179,7 +182,7 @@ class PersonViewModel
GetItemsRequest(
personIds = listOf(itemId),
includeItemTypes = listOf(type),
- fields = SlimItemFields,
+ fields = ItemRowFields,
recursive = true,
sortBy = listOf(ItemSortBy.PREMIERE_DATE, ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.DESCENDING, SortOrder.DESCENDING, SortOrder.ASCENDING),
@@ -219,6 +222,7 @@ data class PersonState(
val series: RowLoadingState = RowLoadingState.Pending,
val episodes: RowLoadingState = RowLoadingState.Pending,
val discovered: List