diff --git a/.github/ISSUE_TEMPLATE/02-new-feature.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml
similarity index 75%
rename from .github/ISSUE_TEMPLATE/02-new-feature.yml
rename to .github/DISCUSSION_TEMPLATE/ideas.yml
index 500beea4..5d1b4346 100644
--- a/.github/ISSUE_TEMPLATE/02-new-feature.yml
+++ b/.github/DISCUSSION_TEMPLATE/ideas.yml
@@ -1,6 +1,4 @@
-name: "Feature Request"
-description: Request a new feature
-title: "[FEA] -
"
+title: ""
labels: [
"enhancement"
]
diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml
index f67a7db7..1aab31da 100644
--- a/.github/ISSUE_TEMPLATE/00-bug.yml
+++ b/.github/ISSUE_TEMPLATE/00-bug.yml
@@ -1,6 +1,6 @@
name: "Bug Report"
description: Report a bug
-title: "[BUG] - "
+title: ""
labels: [
"bug"
]
@@ -28,7 +28,7 @@ body:
attributes:
label: "App Version"
description: What version of the app?
- placeholder: "v0.3.0"
+ placeholder: "v0.5.1"
validations:
required: true
- type: input
@@ -36,7 +36,7 @@ body:
attributes:
label: "Server Version"
description: What version of the server?
- placeholder: "10.11.3"
+ placeholder: "10.11.6"
validations:
required: true
- type: input
diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml
index 2dc73ff6..34dc317b 100644
--- a/.github/ISSUE_TEMPLATE/01-playback.yml
+++ b/.github/ISSUE_TEMPLATE/01-playback.yml
@@ -1,6 +1,6 @@
name: "Playback Problem"
description: Report an issue with playback
-title: "[BUG] - "
+title: ""
labels: [
"bug", "playback"
]
@@ -17,6 +17,7 @@ body:
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: dropdown
@@ -35,7 +36,7 @@ body:
attributes:
label: "App Version"
description: What version of the app?
- placeholder: "v0.3.0"
+ placeholder: "v0.5.1"
validations:
required: true
- type: input
@@ -43,7 +44,7 @@ body:
attributes:
label: "Server Version"
description: What version of the server?
- placeholder: "10.11.3"
+ placeholder: "10.11.6"
validations:
required: true
- type: input
diff --git a/.github/ISSUE_TEMPLATE/03-question.yml b/.github/ISSUE_TEMPLATE/03-question.yml
deleted file mode 100644
index bd10bd46..00000000
--- a/.github/ISSUE_TEMPLATE/03-question.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-name: "Question"
-description: Ask a question
-title: "[QST] - "
-labels: [
- "question"
-]
-body:
- - type: textarea
- id: description
- attributes:
- label: "Question"
- description: Please enter your question
- placeholder: "How do I...?"
- validations:
- required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..2ee91c39
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: New feature / UI suggestion
+ url: https://github.com/damontecres/Wholphin/discussions/new?category=ideas
+ about: Please request new features here
+ - name: Questions
+ url: https://github.com/damontecres/Wholphin/discussions/new?category=q-a
+ about: Please ask questions here
diff --git a/.github/actions/native-build/action.yml b/.github/actions/native-build/action.yml
index 309c6b59..770e552a 100644
--- a/.github/actions/native-build/action.yml
+++ b/.github/actions/native-build/action.yml
@@ -10,7 +10,7 @@ runs:
- name: Load ffmpeg module cache
id: cache_ffmpeg_module
if: ${{ inputs.cache }}
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@v5
with:
path: |
app/libs
@@ -18,7 +18,7 @@ runs:
- name: Load libmpv module cache
id: cache_libmpv_module
if: ${{ inputs.cache }}
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@v5
with:
path: |
app/src/main/libs
@@ -43,7 +43,7 @@ runs:
./build_ffmpeg_decoder.sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}"
- name: Save ffmpeg module cache
id: cache_ffmpeg_module_save
- uses: actions/cache/save@v4
+ uses: actions/cache/save@v5
with:
path: |
app/libs
@@ -75,7 +75,7 @@ runs:
#ln -s libs jniLibs
- name: Save libmpv module cache
id: cache_libmpv_module_save
- uses: actions/cache/save@v4
+ uses: actions/cache/save@v5
with:
path: |
app/src/main/libs
diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index 1153cc97..774dae38 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -6,9 +6,9 @@ runs:
steps:
# Setup the SDKs
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
- python-version: '3.12'
+ python-version: '3.14'
- name: Setup JDK
uses: actions/setup-java@v5
with:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 539f0572..9583525d 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -25,7 +25,7 @@ jobs:
contents: write
steps:
- name: Checkout the code
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
with:
fetch-depth: 0 # Need the tags to build
- name: Setup
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 56513c4b..0b0f2204 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -16,13 +16,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the code
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
with:
fetch-depth: 0 # Need the tags to build
- name: Setup Python
uses: actions/setup-python@v6
with:
- python-version: '3.12'
+ python-version: '3.14'
- name: Run pre-commit
uses: pre-commit/action@v3.0.1
@@ -31,7 +31,7 @@ jobs:
needs: pre-commit
steps:
- name: Checkout the code
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
with:
fetch-depth: 0 # Need the tags to build
- name: Setup
@@ -44,19 +44,18 @@ jobs:
id: buildapp
run: |
./gradlew clean assembleDebug testDebugUnitTest --no-daemon
- apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
+ apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//')
echo "apks=$apks" >> "$GITHUB_OUTPUT"
- - name: Tar build dirs
+
+ test-patch:
+ runs-on: ubuntu-latest
+ needs: pre-commit
+ steps:
+ - name: Checkout the code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+ - name: Test applying patch
run: |
- tar -czf build.tgz ./app/.
- - uses: actions/upload-artifact@v5
- id: upload-build-dirs
- with:
- name: "${{ env.BUILD_DIRS_ARTIFACT }}"
- path: build.tgz
- if-no-files-found: error
- - uses: actions/upload-artifact@v5
- with:
- name: APKs
- path: "${{ steps.buildapp.outputs.apks }}"
- compression-level: 0
+ git apply app/src/patches/play_store.patch
+ git diff
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7c5c45f9..9611c810 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -21,7 +21,7 @@ jobs:
contents: write
steps:
- name: Checkout the code
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
with:
fetch-depth: 0 # Need the tags to build
- name: Setup
@@ -39,11 +39,24 @@ jobs:
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
run: |
./gradlew clean assembleRelease --no-daemon
+
+ - name: Build app
+ id: buildaab
+ env:
+ KEY_ALIAS: "${{ secrets.KEY_ALIAS }}"
+ KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
+ KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
+ SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
+ run: |
+ git apply app/src/patches/play_store.patch
+ ./gradlew bundleRelease --no-daemon
+ aab=$(find app/build/outputs -name '*.aab')
+ echo "aab=$aab" >> "$GITHUB_OUTPUT"
- name: Verify signatures
run: |
echo "Verify APK signatures"
- find app/build/outputs/apk -name '*.apk'
- find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs
+ find app/build/outputs \( -name '*.apk' -or -name '*.aab' \)
+ find app/build/outputs \( -name '*.apk' \) -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs
- name: Copy APK to shorter names
id: apks
run: |
@@ -59,11 +72,18 @@ jobs:
- name: Checksums
run: |
echo "SHA256 checksums:"
- find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
+ find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
+ - name: Upload AAB
+ uses: actions/upload-artifact@v7
+ with:
+ name: AAB
+ path: |
+ app/build/outputs/bundle/**/*.aab
+ compression-level: 0
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ env.TAG_NAME }}" \
- --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "Placeholder" --draft \
+ --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \
"app/build/outputs/apk/**/*.apk"
diff --git a/README.md b/README.md
index b4121cb9..6a3c4c65 100644
--- a/README.md
+++ b/README.md
@@ -24,12 +24,14 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
-
+
## Features
### User interface
+- Customize the home page to see the content you are interested in
+ - Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows!
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows
- Option to combine Continue Watching & Next Up rows
@@ -112,14 +114,21 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin
## Additional screenshots
+### Customized home page
+
+
### Movie library browsing
-
+
### Movie page
-
+
### Series page
-
+
+
+### Genres in library
+
+
### Playlist
-
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 66d09bf8..b5f86522 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -7,7 +7,6 @@ import java.util.Properties
plugins {
alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt)
@@ -45,42 +44,9 @@ android {
targetSdk = 36
versionCode = gitTags.trim().lines().size
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
}
- buildTypes {
- release {
- isMinifyEnabled = true
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro",
- )
- isDebuggable = false
- }
- debug {
- isMinifyEnabled = false
- isDebuggable = true
- applicationIdSuffix = ".debug"
- }
- }
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
- isCoreLibraryDesugaringEnabled = true
- }
- kotlin {
- compilerOptions {
- jvmTarget = JvmTarget.JVM_11
- javaParameters = true
- }
- }
- buildFeatures {
- buildConfig = true
- compose = true
- }
- room {
- schemaDirectory("$projectDir/schemas")
- }
signingConfigs {
if (shouldSign) {
create("ci") {
@@ -98,14 +64,15 @@ android {
}
}
}
+
buildTypes {
release {
isMinifyEnabled = false
-
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
+ isDebuggable = false
if (shouldSign) {
signingConfig = signingConfigs.getByName("ci")
} else {
@@ -120,10 +87,11 @@ android {
}
}
}
+
debug {
- if (shouldSign) {
- signingConfig = signingConfigs.getByName("ci")
- }
+ isMinifyEnabled = false
+ isDebuggable = true
+ applicationIdSuffix = ".debug"
}
applicationVariants.all {
@@ -138,6 +106,25 @@ android {
}
}
}
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ isCoreLibraryDesugaringEnabled = true
+ }
+ kotlin {
+ compilerOptions {
+ languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
+ jvmTarget = JvmTarget.JVM_11
+ javaParameters = true
+ }
+ }
+ buildFeatures {
+ buildConfig = true
+ compose = true
+ }
+ room {
+ schemaDirectory("$projectDir/schemas")
+ }
splits {
abi {
@@ -150,7 +137,13 @@ android {
sourceSets {
getByName("main") {
- kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin")
+ kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
+ }
+ }
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
}
}
}
@@ -223,6 +216,7 @@ dependencies {
implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.tv.material)
implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore)
implementation(libs.protobuf.kotlin.lite)
@@ -266,6 +260,8 @@ dependencies {
implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.room.testing)
implementation(libs.androidx.palette.ktx)
+ implementation(libs.androidx.media3.effect)
+ implementation(libs.androidx.runner)
ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler)
ksp(libs.androidx.hilt.compiler)
@@ -284,6 +280,8 @@ dependencies {
ksp(libs.auto.service.ksp)
implementation(platform(libs.okhttp.bom))
implementation(libs.okhttp)
+ implementation(libs.kache)
+ implementation(libs.kache.file)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
@@ -299,5 +297,12 @@ dependencies {
testImplementation(libs.mockk.android)
testImplementation(libs.mockk.agent)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.androidx.core.testing)
testImplementation(libs.robolectric)
+ testImplementation(libs.hilt.android.testing)
+ testImplementation(libs.androidx.compose.ui.test.junit4)
+ androidTestImplementation(libs.mockk.android)
+ androidTestImplementation(libs.hilt.android.testing)
+ androidTestImplementation(libs.androidx.ui.test.manifest)
}
diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json
new file mode 100644
index 00000000..715fb618
--- /dev/null
+++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json
@@ -0,0 +1,635 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 30,
+ "identityHash": "f9b86b73e972aa002238d43ab2c8dcc2",
+ "entities": [
+ {
+ "tableName": "servers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` 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"
+ }
+ ],
+ "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, 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"
+ }
+ ],
+ "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, 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
+ }
+ ],
+ "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"
+ ]
+ }
+ ]
+ }
+ ],
+ "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, 'f9b86b73e972aa002238d43ab2c8dcc2')"
+ ]
+ }
+}
diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json
new file mode 100644
index 00000000..b719e5f4
--- /dev/null
+++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json
@@ -0,0 +1,642 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 31,
+ "identityHash": "c6829d764ec85321ab3be9905d6c0e3a",
+ "entities": [
+ {
+ "tableName": "servers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` 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"
+ }
+ ],
+ "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, 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"
+ }
+ ],
+ "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"
+ ]
+ }
+ ]
+ }
+ ],
+ "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, 'c6829d764ec85321ab3be9905d6c0e3a')"
+ ]
+ }
+}
diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt
new file mode 100644
index 00000000..72cf2896
--- /dev/null
+++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt
@@ -0,0 +1,75 @@
+package com.github.damontecres.wholphin.test
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performKeyInput
+import androidx.compose.ui.test.pressKey
+import com.github.damontecres.wholphin.MainContent
+import com.github.damontecres.wholphin.services.ScreensaverService
+import com.github.damontecres.wholphin.services.ScreensaverState
+import com.github.damontecres.wholphin.services.SetupDestination
+import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.theme.WholphinTheme
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+@HiltAndroidTest
+class InstrumentedBasicUiTests {
+ @get:Rule(order = 0)
+ var hiltRule = HiltAndroidRule(this)
+
+ @get:Rule(order = 1)
+ val composeTestRule = createAndroidComposeRule()
+
+ lateinit var screensaverService: ScreensaverService
+
+ @Before
+ fun setup() {
+ screensaverService = mockk(relaxed = true)
+ every { screensaverService.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun myTest() {
+ // Start the app
+ composeTestRule.setContent {
+ WholphinTheme {
+ MainContent(
+ backStack = mutableListOf(SetupDestination.ServerList),
+ navigationManager = mockk(relaxed = true),
+ appPreferences = mockk(relaxed = true),
+ backdropService = mockk(relaxed = true),
+ screensaverService = screensaverService,
+ requestedDestination = Destination.Home(),
+ modifier = Modifier,
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithText("Add Server").assertExists()
+ composeTestRule.onNodeWithTag("add_server").performKeyInput {
+ pressKey(Key.DirectionDown) // TODO
+ }
+ composeTestRule.onNodeWithTag("add_server").performClickEnter()
+
+ composeTestRule.onNodeWithText("Discovered Servers").assertExists()
+ }
+}
+
+@OptIn(ExperimentalTestApi::class)
+fun SemanticsNodeInteraction.performClickEnter() =
+ performKeyInput {
+ pressKey(Key.DirectionCenter)
+ }
diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt
new file mode 100644
index 00000000..9aa51656
--- /dev/null
+++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt
@@ -0,0 +1,7 @@
+package com.github.damontecres.wholphin.test
+
+import androidx.activity.ComponentActivity
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class TestActivity : ComponentActivity()
diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt
new file mode 100644
index 00000000..694186ec
--- /dev/null
+++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt
@@ -0,0 +1,14 @@
+package com.github.damontecres.wholphin.test
+
+import android.app.Application
+import android.content.Context
+import androidx.test.runner.AndroidJUnitRunner
+import dagger.hilt.android.testing.HiltTestApplication
+
+class WholphinTestRunner : AndroidJUnitRunner() {
+ override fun newApplication(
+ cl: ClassLoader?,
+ name: String?,
+ context: Context?,
+ ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context)
+}
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 00000000..29b2eb75
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 161352af..4479ae86 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -68,6 +68,17 @@
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
index 0f8b7ee4..e2afbfa0 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
@@ -3,6 +3,7 @@ package com.github.damontecres.wholphin
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
+import android.view.KeyEvent
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.viewModels
@@ -10,8 +11,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -19,27 +18,16 @@ 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.RectangleShape
-import androidx.compose.ui.unit.dp
+import androidx.compose.ui.graphics.Color
import androidx.datastore.core.DataStore
-import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
-import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
-import androidx.navigation3.runtime.NavEntry
-import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
-import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.ExperimentalTvMaterial3Api
-import androidx.tv.material3.MaterialTheme
-import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
-import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
@@ -48,27 +36,36 @@ import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService
+import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ServerEventListener
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
+import com.github.damontecres.wholphin.services.SuggestionsSchedulerService
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.UserSwitchListener
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
+import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
-import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination
-import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
-import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
-import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
+import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@@ -113,6 +110,12 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
+ @Inject
+ lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
+
+ @Inject
+ lateinit var backdropService: BackdropService
+
// Note: unused but injected to ensure it is created
@Inject
lateinit var serverEventListener: ServerEventListener
@@ -121,22 +124,20 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
+ @Inject
+ lateinit var screensaverService: ScreensaverService
+
private var signInAuto = true
@OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ instance = this
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver)
if (savedInstanceState == null) {
- appUpgradeHandler.copySubfont(false)
- }
- refreshRateService.refreshRateMode.observe(this) { modeId ->
- // Listen for refresh rate changes
- val attrs = window.attributes
- if (attrs.preferredDisplayModeId != modeId) {
- Timber.d("Switch preferredDisplayModeId to %s", modeId)
- window.attributes = attrs.apply { preferredDisplayModeId = modeId }
+ lifecycleScope.launchIO {
+ appUpgradeHandler.copySubfont(false)
}
}
viewModel.serverRepository.currentUser.observe(this) { user ->
@@ -149,9 +150,42 @@ class MainActivity : AppCompatActivity() {
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
+ screensaverService.keepScreenOn
+ .onEach { keepScreenOn ->
+ Timber.v("keepScreenOn: %s", keepScreenOn)
+ withContext(Dispatchers.Main) {
+ if (keepScreenOn) {
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ } else {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+ }
+ }.catch { ex ->
+ Timber.e(ex, "Error with keepScreenOn")
+ }.launchIn(lifecycleScope)
+
viewModel.appStart()
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
@@ -177,127 +211,41 @@ class MainActivity : AppCompatActivity() {
true,
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
) {
- Surface(
- modifier =
- Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.background),
- shape = RectangleShape,
- ) {
-// val backStack = rememberNavBackStack(SetupDestination.Loading)
-// setupNavigationManager.backStack = backStack
- val backStack = setupNavigationManager.backStack
- NavDisplay(
- backStack = backStack,
- onBack = { backStack.removeLastOrNull() },
- entryDecorators =
- listOf(
- rememberSaveableStateHolderNavEntryDecorator(),
- rememberViewModelStoreNavEntryDecorator(),
- ),
- entryProvider = { key ->
- key as SetupDestination
- NavEntry(key) {
- when (key) {
- SetupDestination.Loading -> {
- Box(
- modifier = Modifier.size(200.dp),
- contentAlignment = Alignment.Center,
- ) {
- CircularProgressIndicator(
- color = MaterialTheme.colorScheme.border,
- modifier = Modifier.align(Alignment.Center),
- )
- }
- }
-
- SetupDestination.ServerList -> {
- SwitchServerContent(Modifier.fillMaxSize())
- }
-
- is SetupDestination.UserList -> {
- SwitchUserContent(
- currentServer = key.server,
- Modifier.fillMaxSize(),
- )
- }
-
- is SetupDestination.AppContent -> {
- val current = key.current
- ProvideLocalClock {
- if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
- LaunchedEffect(Unit) {
- try {
- updateChecker.maybeShowUpdateToast(
- appPreferences.updateUrl,
- )
- } catch (ex: Exception) {
- Timber.w(
- ex,
- "Exception during update check",
- )
- }
- }
- }
- val appPreferences by userPreferencesDataStore.data.collectAsState(
- appPreferences,
- )
- val preferences =
- remember(appPreferences) {
- UserPreferences(appPreferences)
- }
- var showContent by remember {
- mutableStateOf(true)
- }
- LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
- if (!preferences.appPreferences.signInAutomatically) {
- showContent = false
- }
- }
-
- if (showContent) {
- val requestedDestination =
- remember(intent) {
- intent?.let(::extractDestination)
- }
- ApplicationContent(
- user = current.user,
- server = current.server,
- startDestination =
- requestedDestination
- ?: Destination.Home(),
- navigationManager = navigationManager,
- preferences = preferences,
- modifier = Modifier.fillMaxSize(),
- )
- } else {
- Box(
- modifier = Modifier.size(200.dp),
- contentAlignment = Alignment.Center,
- ) {
- CircularProgressIndicator(
- color = MaterialTheme.colorScheme.border,
- modifier = Modifier.align(Alignment.Center),
- )
- }
- }
- }
- }
- }
- }
- },
- )
- }
+ val requestedDestination =
+ remember(intent) {
+ intent?.let(::extractDestination) ?: Destination.Home()
+ }
+ MainContent(
+ backStack = setupNavigationManager.backStack,
+ navigationManager = navigationManager,
+ appPreferences = appPreferences,
+ backdropService = backdropService,
+ screensaverService = screensaverService,
+ requestedDestination = requestedDestination,
+ modifier = Modifier.fillMaxSize(),
+ )
}
}
}
}
}
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ if (screensaverService.state.value.show) {
+ screensaverService.stop(false)
+ screensaverService.pulse()
+ return true
+ } else {
+ screensaverService.pulse()
+ return super.dispatchKeyEvent(event)
+ }
+ }
+
override fun onResume() {
super.onResume()
Timber.d("onResume")
- lifecycleScope.launchIO {
+ lifecycleScope.launchDefault {
+ screensaverService.pulse()
appUpgradeHandler.run()
}
}
@@ -311,6 +259,7 @@ class MainActivity : AppCompatActivity() {
override fun onStop() {
super.onStop()
Timber.d("onStop")
+ screensaverService.stop(true)
tvProviderSchedulerService.launchOneTimeRefresh()
}
@@ -322,6 +271,22 @@ class MainActivity : AppCompatActivity() {
override fun onStart() {
super.onStart()
Timber.d("onStart")
+
+ lifecycleScope.launchDefault {
+ val appPreferences = userPreferencesDataStore.data.first()
+ if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
+ try {
+ updateChecker.maybeShowUpdateToast(
+ appPreferences.updateUrl,
+ )
+ } catch (ex: Exception) {
+ Timber.w(
+ ex,
+ "Exception during update check",
+ )
+ }
+ }
+ }
}
override fun onSaveInstanceState(outState: Bundle) {
@@ -384,6 +349,16 @@ class MainActivity : AppCompatActivity() {
}
}
+ 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 }
+ }
+ }
+ }
+
companion object {
const val INTENT_ITEM_ID = "itemId"
const val INTENT_ITEM_TYPE = "itemType"
@@ -391,6 +366,9 @@ class MainActivity : AppCompatActivity() {
const val INTENT_EPISODE_NUMBER = "epNum"
const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId"
+
+ lateinit var instance: MainActivity
+ private set
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
new file mode 100644
index 00000000..982240a4
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
@@ -0,0 +1,149 @@
+package com.github.damontecres.wholphin
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+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.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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
+import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
+import androidx.navigation3.runtime.NavEntry
+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
+import com.github.damontecres.wholphin.services.ScreensaverService
+import com.github.damontecres.wholphin.services.SetupDestination
+import com.github.damontecres.wholphin.ui.components.AppScreensaver
+import com.github.damontecres.wholphin.ui.nav.ApplicationContent
+import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
+import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
+import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
+
+@Composable
+fun MainContent(
+ backStack: MutableList,
+ navigationManager: NavigationManager,
+ appPreferences: AppPreferences,
+ backdropService: BackdropService,
+ screensaverService: ScreensaverService,
+ requestedDestination: Destination,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier =
+ modifier
+ .background(MaterialTheme.colorScheme.background),
+ shape = RectangleShape,
+ ) {
+// val backStack = rememberNavBackStack(SetupDestination.Loading)
+// setupNavigationManager.backStack = backStack
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ entryDecorators =
+ listOf(
+ rememberSaveableStateHolderNavEntryDecorator(),
+ rememberViewModelStoreNavEntryDecorator(),
+ ),
+ entryProvider = { key ->
+ key as SetupDestination
+ NavEntry(key) {
+ when (key) {
+ SetupDestination.Loading -> {
+ Box(
+ modifier = Modifier.size(200.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(
+ color = MaterialTheme.colorScheme.border,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ }
+
+ SetupDestination.ServerList -> {
+ SwitchServerContent(Modifier.fillMaxSize())
+ }
+
+ is SetupDestination.UserList -> {
+ SwitchUserContent(
+ currentServer = key.server,
+ Modifier.fillMaxSize(),
+ )
+ }
+
+ is SetupDestination.AppContent -> {
+ LaunchedEffect(Unit) {
+ backdropService.clearBackdrop()
+ }
+ val current = key.current
+ ProvideLocalClock {
+ val preferences =
+ remember(appPreferences) {
+ UserPreferences(appPreferences)
+ }
+ var showContent by remember {
+ mutableStateOf(true)
+ }
+ LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
+ if (!appPreferences.signInAutomatically) {
+ showContent = false
+ }
+ }
+
+ if (showContent) {
+ ApplicationContent(
+ user = current.user,
+ server = current.server,
+ startDestination = requestedDestination,
+ navigationManager = navigationManager,
+ preferences = preferences,
+ modifier = Modifier.fillMaxSize(),
+ )
+ } else {
+ Box(
+ modifier = Modifier.size(200.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(
+ color = MaterialTheme.colorScheme.border,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ )
+ val screenSaverState by screensaverService.state.collectAsState()
+ if (screenSaverState.enabled || screenSaverState.enabledTemp) {
+ AnimatedVisibility(
+ screenSaverState.show,
+ Modifier.fillMaxSize(),
+ ) {
+ AppScreensaver(appPreferences, Modifier.fillMaxSize())
+ }
+ }
+ }
+}
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 cf2c159a..ceb7264e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
@@ -3,7 +3,6 @@ package com.github.damontecres.wholphin
import android.app.Application
import android.os.Build
import android.os.StrictMode
-import android.os.StrictMode.ThreadPolicy
import android.util.Log
import androidx.compose.runtime.Composer
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
@@ -27,16 +26,6 @@ class WholphinApplication :
init {
instance = this
- if (BuildConfig.DEBUG) {
- StrictMode.setThreadPolicy(
- ThreadPolicy
- .Builder()
- .detectNetwork()
- .penaltyLog()
- .build(),
- )
- }
-
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
@@ -64,6 +53,23 @@ class WholphinApplication :
override fun onCreate() {
super.onCreate()
+ if (BuildConfig.DEBUG) {
+ StrictMode.setThreadPolicy(
+ StrictMode.ThreadPolicy
+ .Builder()
+ .detectNetwork()
+ .penaltyLog()
+ .penaltyDeathOnNetwork()
+ .build(),
+ )
+// StrictMode.setVmPolicy(
+// StrictMode.VmPolicy
+// .Builder()
+// .detectAll()
+// .penaltyLog()
+// .build(),
+// )
+ }
OkHttp.initialize(this)
initAcra {
buildConfigClass = BuildConfig::class.java
diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
new file mode 100644
index 00000000..4d77c3a8
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
@@ -0,0 +1,101 @@
+package com.github.damontecres.wholphin
+
+import android.service.dreams.DreamService
+import androidx.compose.foundation.layout.fillMaxSize
+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.platform.ComposeView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.ScreensaverService
+import com.github.damontecres.wholphin.services.UserPreferencesService
+import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
+import com.github.damontecres.wholphin.ui.theme.WholphinTheme
+import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.collectLatest
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
+
+@AndroidEntryPoint
+class WholphinDreamService :
+ DreamService(),
+ SavedStateRegistryOwner {
+ @Inject
+ lateinit var screensaverService: ScreensaverService
+
+ @Inject
+ lateinit var userPreferencesService: UserPreferencesService
+
+ private val lifecycleRegistry = LifecycleRegistry(this)
+
+ private val savedStateRegistryController =
+ SavedStateRegistryController.create(this).apply {
+ performAttach()
+ }
+
+ override val lifecycle: Lifecycle get() = lifecycleRegistry
+ override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
+
+ override fun onCreate() {
+ super.onCreate()
+
+ savedStateRegistryController.performRestore(null)
+ lifecycleRegistry.currentState = Lifecycle.State.CREATED
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ val itemFlow = screensaverService.createItemFlow(lifecycleScope)
+ setContentView(
+ ComposeView(this).apply {
+ setViewTreeLifecycleOwner(this@WholphinDreamService)
+ setViewTreeSavedStateRegistryOwner(this@WholphinDreamService)
+ setContent {
+ var prefs by remember { mutableStateOf(null) }
+ LaunchedEffect(Unit) {
+ userPreferencesService.flow.collectLatest { prefs = it }
+ }
+ prefs?.let { prefs ->
+ WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) {
+ ProvideLocalClock {
+ val screensaverPrefs =
+ prefs.appPreferences.interfacePreferences.screensaverPreference
+ val currentItem by itemFlow.collectAsState(null)
+ AppScreensaverContent(
+ currentItem = currentItem,
+ showClock = screensaverPrefs.showClock,
+ duration = screensaverPrefs.duration.milliseconds,
+ animate = screensaverPrefs.animate,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+ }
+ }
+ },
+ )
+ }
+
+ override fun onDreamingStarted() {
+ super.onDreamingStarted()
+ lifecycleRegistry.currentState = Lifecycle.State.STARTED
+ }
+
+ override fun onDreamingStopped() {
+ super.onDreamingStopped()
+ lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
+ }
+}
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 19e33616..b3f33bed 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
@@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
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.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
@@ -32,12 +33,14 @@ import java.util.UUID
ItemPlayback::class,
NavDrawerPinnedItem::class,
LibraryDisplayInfo::class,
+ PlaybackEffect::class,
PlaybackLanguageChoice::class,
ItemTrackModification::class,
SeerrServer::class,
SeerrUser::class,
+
],
- version = 20,
+ version = 31,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@@ -50,6 +53,8 @@ import java.util.UUID
AutoMigration(10, 11),
AutoMigration(11, 12),
AutoMigration(12, 20),
+ AutoMigration(20, 30),
+ AutoMigration(30, 31),
],
)
@TypeConverters(Converters::class)
@@ -65,6 +70,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
abstract fun seerrServerDao(): SeerrServerDao
+
+ abstract fun playbackEffectDao(): PlaybackEffectDao
}
class Converters {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt
deleted file mode 100644
index 2312c62c..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt
+++ /dev/null
@@ -1,97 +0,0 @@
-package com.github.damontecres.wholphin.data
-
-import android.content.Context
-import com.github.damontecres.wholphin.data.model.BaseItem
-import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
-import com.github.damontecres.wholphin.data.model.NavPinType
-import com.github.damontecres.wholphin.services.SeerrServerRepository
-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.util.supportedCollectionTypes
-import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.flow.first
-import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.api.client.extensions.liveTvApi
-import org.jellyfin.sdk.api.client.extensions.userViewsApi
-import org.jellyfin.sdk.model.api.CollectionType
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class NavDrawerItemRepository
- @Inject
- constructor(
- @param:ApplicationContext private val context: Context,
- private val api: ApiClient,
- private val serverRepository: ServerRepository,
- private val serverPreferencesDao: ServerPreferencesDao,
- private val seerrServerRepository: SeerrServerRepository,
- ) {
- suspend fun getNavDrawerItems(): List {
- val user = serverRepository.currentUser.value
- val tvAccess =
- serverRepository.currentUserDto.value
- ?.policy
- ?.enableLiveTvAccess ?: false
- val userViews =
- api.userViewsApi
- .getUserViews(userId = user?.id)
- .content.items
- val recordingFolders =
- if (tvAccess) {
- api.liveTvApi
- .getRecordingFolders(userId = user?.id)
- .content.items
- .map { it.id }
- .toSet()
- } else {
- setOf()
- }
-
- val builtins =
- if (seerrServerRepository.active.first()) {
- listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
- } else {
- listOf(NavDrawerItem.Favorites)
- }
-
- val libraries =
- userViews
- .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
- .map {
- val destination =
- if (it.id in recordingFolders) {
- Destination.Recordings(it.id)
- } else {
- BaseItem.from(it, api).destination()
- }
- ServerNavDrawerItem(
- itemId = it.id,
- name = it.name ?: it.id.toString(),
- destination = destination,
- type = it.collectionType ?: CollectionType.UNKNOWN,
- )
- }
- return builtins + libraries
- }
-
- suspend fun getFilteredNavDrawerItems(items: List): List {
- val user = serverRepository.currentUser.value
- val navDrawerPins =
- user
- ?.let {
- serverPreferencesDao.getNavDrawerPinnedItems(it)
- }.orEmpty()
- val filtered = items.filter { navDrawerPins.isPinned(it.id) }
- if (items.size != filtered.size) {
- // Some were filtered out, check if should include More
- if (navDrawerPins.isPinned(NavDrawerItem.More.id)) {
- return filtered + listOf(NavDrawerItem.More)
- }
- }
- return filtered
- }
- }
-
-fun List.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt
new file mode 100644
index 00000000..0fa9f0ee
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.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.PlaybackEffect
+import org.jellyfin.sdk.model.api.BaseItemKind
+import java.util.UUID
+
+@Dao
+interface PlaybackEffectDao {
+ @Query("SELECT * FROM playback_effects WHERE jellyfinUserRowId=:jellyfinUserRowId AND itemId=:itemId AND type=:type")
+ suspend fun getPlaybackEffect(
+ jellyfinUserRowId: Int,
+ itemId: UUID,
+ type: BaseItemKind,
+ ): PlaybackEffect?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(playbackEffect: PlaybackEffect)
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt
index 59f5d9ea..3a2c325c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt
@@ -47,7 +47,7 @@ interface SeerrServerDao {
suspend fun deleteUser(
serverId: Int,
jellyfinUserRowId: Int,
- )
+ ): Int
suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId)
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 f66dcfca..2d786ed8 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,15 +9,18 @@ import androidx.lifecycle.map
import com.github.damontecres.wholphin.data.model.JellyfinServer
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.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.AuthenticationResult
@@ -40,9 +43,8 @@ class ServerRepository
val serverDao: JellyfinServerDao,
val apiClient: ApiClient,
val userPreferencesDataStore: DataStore,
+ @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
- private val sharedPreferences = getServerSharedPreferences(context)
-
private var _current = EqualityMutableLiveData(null)
val current: LiveData = _current
@@ -58,7 +60,7 @@ class ServerRepository
* The current user is removed
*/
suspend fun addAndChangeServer(server: JellyfinServer) {
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
serverDao.addOrUpdateServer(server)
}
apiClient.update(baseUrl = server.url, accessToken = null)
@@ -72,7 +74,7 @@ class ServerRepository
server: JellyfinServer,
user: JellyfinUser,
): CurrentUser? =
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
if (server.id != user.serverId) {
throw IllegalStateException("User is not part of the server")
}
@@ -106,7 +108,7 @@ class ServerRepository
_current.value = CurrentUser(updatedServer, updatedUser)
_currentUserDto.value = userDto
}
- sharedPreferences.edit(true) {
+ getServerSharedPreferences(context).edit(true) {
putString(SERVER_URL_KEY, updatedServer.url)
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
}
@@ -127,7 +129,7 @@ class ServerRepository
return null
}
val serverAndUsers =
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
serverDao.getServer(serverId)
}
if (serverAndUsers != null) {
@@ -150,7 +152,7 @@ class ServerRepository
}
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
serverId?.let { serverDao.getServer(serverId)?.server }
}
@@ -164,7 +166,7 @@ class ServerRepository
suspend fun changeUser(
serverUrl: String,
authenticationResult: AuthenticationResult,
- ) = withContext(Dispatchers.IO) {
+ ) = withContext(ioDispatcher) {
val accessToken = authenticationResult.accessToken
if (accessToken != null) {
val authedUser = authenticationResult.user
@@ -214,7 +216,7 @@ class ServerRepository
}
apiClient.update(accessToken = null)
}
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
serverDao.deleteUser(user.serverId, user.id)
}
}
@@ -234,7 +236,7 @@ class ServerRepository
}
apiClient.update(baseUrl = null, accessToken = null)
}
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
serverDao.deleteServer(server.id)
}
}
@@ -253,7 +255,7 @@ class ServerRepository
suspend fun setUserPin(
user: JellyfinUser,
pin: String?,
- ) = withContext(Dispatchers.IO) {
+ ) = withContext(ioDispatcher) {
val newUser = user.copy(pin = pin)
val updatedUser = serverDao.addOrUpdateUser(newUser)
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
@@ -265,6 +267,17 @@ class ServerRepository
}
}
+ suspend fun authorizeQuickConnect(code: String): Boolean =
+ withContext(ioDispatcher) {
+ val userId = currentUser.value?.id
+ if (userId == null) {
+ Timber.e("No user logged in for Quick Connect authorization")
+ throw IllegalStateException("Must be logged in to authorize Quick Connect")
+ }
+ val response = apiClient.quickConnectApi.authorizeQuickConnect(code, userId)
+ response.content
+ }
+
companion object {
fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(
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 a9673131..3c34851b 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
@@ -25,6 +25,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks
import java.util.Locale
+import java.util.UUID
import kotlin.time.Duration
@Serializable
@@ -32,6 +33,8 @@ import kotlin.time.Duration
data class BaseItem(
val data: BaseItemDto,
val useSeriesForPrimary: Boolean,
+ val imageUrlOverride: String? = null,
+ val destinationOverride: Destination? = null,
) : CardGridItem {
val id get() = data.id
@@ -69,6 +72,8 @@ data class BaseItem(
}
}
+ val canDelete: Boolean get() = data.canDelete == true
+
@Transient
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
@@ -91,12 +96,20 @@ data class BaseItem(
episodeCornerText =
data.indexNumber?.let { "E$it" }
?: data.premiereDate?.let(::formatDateTime),
- episdodeUnplayedCornerText =
- data.indexNumber?.let { "E$it" }
- ?: data.userData
- ?.unplayedItemCount
- ?.takeIf { it > 0 }
- ?.let { abbreviateNumber(it) },
+ episodeUnplayedCornerText =
+ if (type == BaseItemKind.SERIES ||
+ type == BaseItemKind.SEASON ||
+ type == BaseItemKind.EPISODE ||
+ type == BaseItemKind.BOX_SET
+ ) {
+ data.indexNumber?.let { "E$it" }
+ ?: data.userData
+ ?.unplayedItemCount
+ ?.takeIf { it > 0 }
+ ?.let { abbreviateNumber(it) }
+ } else {
+ null
+ },
quickDetails =
buildAnnotatedString {
val details =
@@ -106,6 +119,12 @@ data class BaseItem(
data.premiereDate?.let { add(DateFormatter.format(it)) }
} else if (type == BaseItemKind.SERIES) {
data.seriesProductionYears?.let(::add)
+ } else if (type == BaseItemKind.PHOTO) {
+ if (data.productionYear != null) {
+ add(data.productionYear!!.toString())
+ } else if (data.premiereDate != null) {
+ add(data.premiereDate!!.toLocalDate().toString())
+ }
} else {
data.productionYear?.let { add(it.toString()) }
}
@@ -154,7 +173,8 @@ data class BaseItem(
it.dayOfMonth.toString().padStart(2, '0')
}?.toIntOrNull()
- fun destination(): Destination {
+ fun destination(index: Int? = null): Destination {
+ if (destinationOverride != null) return destinationOverride
val result =
// Redirect episodes & seasons to their series if possible
when (type) {
@@ -176,6 +196,25 @@ data class BaseItem(
)
}
+ BaseItemKind.TV_CHANNEL -> {
+ Destination.Playback(
+ itemId = id,
+ positionMs = 0L,
+ )
+ }
+
+ BaseItemKind.PROGRAM -> {
+ val channelId = data.channelId
+ if (channelId != null) {
+ Destination.Playback(
+ itemId = channelId,
+ positionMs = 0L,
+ )
+ } else {
+ Destination.MediaItem(this)
+ }
+ }
+
else -> {
Destination.MediaItem(this)
}
@@ -201,6 +240,31 @@ val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let {
@Immutable
data class BaseItemUi(
val episodeCornerText: String?,
- val episdodeUnplayedCornerText: String?,
+ val episodeUnplayedCornerText: String?,
val quickDetails: AnnotatedString,
)
+
+fun createGenreDestination(
+ genreId: UUID,
+ genreName: String,
+ parentId: UUID,
+ parentName: String?,
+ includeItemTypes: List?,
+) = Destination.FilteredCollection(
+ itemId = parentId,
+ filter =
+ CollectionFolderFilter(
+ nameOverride =
+ listOfNotNull(
+ genreName,
+ parentName,
+ ).joinToString(" "),
+ filter =
+ GetItemsFilter(
+ genres = listOf(genreId),
+ includeItemTypes = includeItemTypes,
+ ),
+ useSavedLibraryDisplayInfo = false,
+ ),
+ recursive = true,
+)
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 1bcacb3f..3444ac54 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
@@ -31,6 +31,7 @@ data class Chapter(
)
},
)
- }.orEmpty()
+ }?.sortedBy { it.position }
+ .orEmpty()
}
}
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
new file mode 100644
index 00000000..09622733
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
@@ -0,0 +1,239 @@
+@file:UseSerializers(UUIDSerializer::class)
+
+package com.github.damontecres.wholphin.data.model
+
+import com.github.damontecres.wholphin.preferences.PrefContentScale
+import com.github.damontecres.wholphin.ui.AspectRatio
+import com.github.damontecres.wholphin.ui.Cards
+import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
+import com.github.damontecres.wholphin.ui.data.SortAndDirection
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.UseSerializers
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import org.jellyfin.sdk.model.serializer.UUIDSerializer
+import java.util.UUID
+
+@Serializable
+sealed interface HomeRowConfig {
+ val viewOptions: HomeRowViewOptions
+
+ fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig
+
+ /**
+ * Continue watching media that the user has started but not finished
+ */
+ @Serializable
+ @SerialName("ContinueWatching")
+ data class ContinueWatching(
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatching = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Next up row for next episodes in a series the user has started
+ */
+ @Serializable
+ @SerialName("NextUp")
+ data class NextUp(
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): NextUp = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Combined [ContinueWatching] and [NextUp]
+ */
+ @Serializable
+ @SerialName("ContinueWatchingCombined")
+ data class ContinueWatchingCombined(
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatchingCombined = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Media recently added to a library
+ */
+ @Serializable
+ @SerialName("RecentlyAdded")
+ data class RecentlyAdded(
+ val parentId: UUID,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyAdded = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Media recently released (premiere date) in a library
+ */
+ @Serializable
+ @SerialName("RecentlyReleased")
+ data class RecentlyReleased(
+ val parentId: UUID,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyReleased = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Row of a genres in a library
+ */
+ @Serializable
+ @SerialName("Genres")
+ data class Genres(
+ val parentId: UUID,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault,
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Favorites for a specific type
+ */
+ @Serializable
+ @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()
+ },
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Currently recording
+ */
+ @Serializable
+ @SerialName("Recordings")
+ data class Recordings(
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): Recordings = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Programs on now/recommended
+ */
+ @Serializable
+ @SerialName("TvPrograms")
+ data class TvPrograms(
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault,
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Live TV channels
+ */
+ @Serializable
+ @SerialName("TvChannels")
+ data class TvChannels(
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault,
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvChannels = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Fetch suggestions from [com.github.damontecres.wholphin.services.SuggestionService] for the given parent ID
+ */
+ @Serializable
+ @SerialName("Suggestions")
+ data class Suggestions(
+ val parentId: UUID,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): Suggestions = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Fetch by parent ID such as a library, collection, or playlist with optional simple sorting
+ */
+ @Serializable
+ @SerialName("ByParent")
+ data class ByParent(
+ val parentId: UUID,
+ val recursive: Boolean,
+ val sort: SortAndDirection? = null,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): ByParent = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * An arbitrary [GetItemsRequest] allowing to query for anything
+ */
+ @Serializable
+ @SerialName("GetItems")
+ data class GetItems(
+ val name: String,
+ val getItems: GetItemsRequest,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
+ }
+}
+
+/**
+ * Root class for home page settings
+ *
+ * Contains the list of rows and a version
+ */
+@Serializable
+@SerialName("HomePageSettings")
+data class HomePageSettings(
+ val rows: List,
+ val version: Int,
+) {
+ companion object {
+ val EMPTY = HomePageSettings(listOf(), SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
+ }
+}
+
+/**
+ * This is the max version supported by this version of the app
+ */
+const val SUPPORTED_HOME_PAGE_SETTINGS_VERSION = 1
+
+/**
+ * View options for displaying a row
+ *
+ * Allows for changing things like height or aspect ratio
+ */
+@Serializable
+data class HomeRowViewOptions(
+ val heightDp: Int = Cards.HEIGHT_2X3_DP,
+ val spacing: Int = 16,
+ val contentScale: PrefContentScale = PrefContentScale.FILL,
+ val aspectRatio: AspectRatio = AspectRatio.TALL,
+ val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
+ val showTitles: Boolean = false,
+ val useSeries: Boolean = true,
+ val episodeContentScale: PrefContentScale = PrefContentScale.FILL,
+ val episodeAspectRatio: AspectRatio = AspectRatio.TALL,
+ val episodeImageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
+) {
+ companion object {
+ val genreDefault =
+ HomeRowViewOptions(
+ heightDp = Cards.HEIGHT_EPISODE,
+ aspectRatio = AspectRatio.WIDE,
+ )
+
+ val liveTvDefault =
+ HomeRowViewOptions(
+ heightDp = 96,
+ aspectRatio = AspectRatio.WIDE,
+ contentScale = PrefContentScale.FIT,
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt
index c12d37b5..ac4a4710 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt
@@ -1,6 +1,10 @@
package com.github.damontecres.wholphin.data.model
+import android.content.Context
+import androidx.annotation.StringRes
import androidx.compose.runtime.Stable
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.UUID
@@ -19,19 +23,21 @@ data class Person(
) {
companion object {
fun fromDto(
+ context: Context,
dto: BaseItemPerson,
api: ApiClient,
): Person =
Person(
id = dto.id,
name = dto.name,
- role = dto.role,
+ role = personRole(context, dto.role, dto.type),
type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = false,
)
fun fromDto(
+ context: Context,
dto: BaseItemPerson,
favorite: Boolean,
api: ApiClient,
@@ -39,10 +45,51 @@ data class Person(
Person(
id = dto.id,
name = dto.name,
- role = dto.role,
+ role = personRole(context, dto.role, dto.type),
type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = favorite,
)
}
}
+
+private fun personRole(
+ context: Context,
+ role: String?,
+ type: PersonKind,
+): String? =
+ if (type == PersonKind.ACTOR || type == PersonKind.GUEST_STAR || type == PersonKind.UNKNOWN) {
+ role
+ } else if (role.equals(type.name, ignoreCase = true)) {
+ type.stringRes?.let { context.getString(it) }
+ } else if (role.isNotNullOrBlank() && role.lowercase().contains(type.name.lowercase())) {
+ role
+ } else {
+ listOfNotNull(
+ role?.takeIf { it.isNotNullOrBlank() },
+ type.stringRes?.let { context.getString(it) },
+ ).takeIf { it.isNotEmpty() }?.joinToString(" - ")
+ }
+
+@get:StringRes
+private val PersonKind.stringRes: Int?
+ get() =
+ when (this) {
+ PersonKind.UNKNOWN -> R.string.unknown
+ PersonKind.ACTOR -> R.string.actor
+ PersonKind.DIRECTOR -> R.string.director
+ PersonKind.COMPOSER -> R.string.composer
+ PersonKind.WRITER -> R.string.writer
+ PersonKind.GUEST_STAR -> R.string.guest_star
+ PersonKind.PRODUCER -> R.string.producer
+ PersonKind.CONDUCTOR -> R.string.conductor
+ PersonKind.LYRICIST -> R.string.lyricist
+ PersonKind.ARRANGER -> R.string.arranger
+ PersonKind.ENGINEER -> R.string.engineer
+ PersonKind.MIXER -> R.string.mixer
+ PersonKind.REMIXER -> R.string.mixer
+ PersonKind.CREATOR -> R.string.creator
+ PersonKind.ARTIST -> R.string.artist
+ PersonKind.ALBUM_ARTIST -> R.string.artist
+ else -> null
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt
new file mode 100644
index 00000000..3c5cf6a0
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt
@@ -0,0 +1,14 @@
+package com.github.damontecres.wholphin.data.model
+
+import androidx.room.Embedded
+import androidx.room.Entity
+import org.jellyfin.sdk.model.api.BaseItemKind
+import java.util.UUID
+
+@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"])
+data class PlaybackEffect(
+ val jellyfinUserRowId: Int,
+ val itemId: UUID,
+ val type: BaseItemKind,
+ @Embedded val videoFilter: VideoFilter,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt
index f3f52585..2b503023 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt
@@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.data.model
+import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@@ -24,4 +25,5 @@ data class NavDrawerPinnedItem(
val userId: Int,
val itemId: String,
val type: NavPinType,
+ @ColumnInfo(defaultValue = "-1") val order: Int,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt
new file mode 100644
index 00000000..e71db95c
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt
@@ -0,0 +1,223 @@
+package com.github.damontecres.wholphin.data.model
+
+import android.graphics.ColorMatrix
+import androidx.annotation.IntRange
+import androidx.annotation.OptIn
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.effect.Brightness
+import androidx.media3.effect.Contrast
+import androidx.media3.effect.GaussianBlur
+import androidx.media3.effect.GlEffect
+import androidx.media3.effect.HslAdjustment
+import androidx.media3.effect.RgbAdjustment
+import androidx.media3.effect.ScaleAndRotateTransformation
+import androidx.room.Ignore
+
+/**
+ * Modifications to a video playback
+ */
+data class VideoFilter(
+ val rotation: Int = 0,
+ @param:IntRange(0, 200) val brightness: Int = COLOR_DEFAULT,
+ @param:IntRange(0, 200) val contrast: Int = COLOR_DEFAULT,
+ @param:IntRange(0, 200) val saturation: Int = COLOR_DEFAULT,
+ @param:IntRange(0, 360) val hue: Int = HUE_DEFAULT,
+ @param:IntRange(0, 200) val red: Int = COLOR_DEFAULT,
+ @param:IntRange(0, 200) val green: Int = COLOR_DEFAULT,
+ @param:IntRange(0, 200) val blue: Int = COLOR_DEFAULT,
+ @param:IntRange(0, 250) val blur: Int = 0,
+) {
+ @Ignore
+ val colorMatrix: androidx.compose.ui.graphics.ColorMatrix = createComposeColorMatrix()
+
+ companion object {
+ const val COLOR_DEFAULT = 100
+ const val HUE_DEFAULT = 0
+ }
+
+ fun isRotated(): Boolean = rotation != 0 && rotation % 360 != 0
+
+ fun hasRgb(): Boolean = red != COLOR_DEFAULT || green != COLOR_DEFAULT || blue != COLOR_DEFAULT
+
+ fun hasBrightness(): Boolean = brightness != COLOR_DEFAULT
+
+ fun hasContrast(): Boolean = contrast != COLOR_DEFAULT
+
+ fun hasHsl(): Boolean = hue != HUE_DEFAULT || saturation != COLOR_DEFAULT
+
+ fun hasBlur(): Boolean = blur > 0
+
+ fun hasImageFilter(): Boolean = hasRgb() || hasBrightness() || hasContrast() || saturation != COLOR_DEFAULT
+
+ @OptIn(UnstableApi::class)
+ private fun rgbAdjustment(): RgbAdjustment =
+ RgbAdjustment
+ .Builder()
+ .setRedScale(red / COLOR_DEFAULT.toFloat())
+ .setGreenScale(green / COLOR_DEFAULT.toFloat())
+ .setBlueScale(blue / COLOR_DEFAULT.toFloat())
+ .build()
+
+ /**
+ * Create the list of effects to apply
+ */
+ @OptIn(UnstableApi::class)
+ fun createEffectList(): List =
+ buildList {
+ if (isRotated()) {
+ add(
+ ScaleAndRotateTransformation
+ .Builder()
+ .setRotationDegrees(rotation.toFloat())
+ .build(),
+ )
+ }
+ if (hasRgb()) {
+ add(rgbAdjustment())
+ }
+ if (hasBrightness()) {
+ add(Brightness((brightness - 100) / 100f))
+ }
+ if (hasContrast()) {
+ add(Contrast((contrast - 100) / 100f))
+ }
+ if (hasHsl()) {
+ add(
+ HslAdjustment
+ .Builder()
+ .adjustHue(hue.toFloat())
+ .adjustSaturation((saturation - 100).toFloat())
+ .build(),
+ )
+ }
+ if (hasBlur()) {
+ add(GaussianBlur(blur / 10f))
+ }
+ }
+
+ private fun saturationMatrix(): FloatArray {
+ val rF = 0.2999f
+ val gF = 0.587f
+ val bF = 0.114f
+ val s = saturation / 100.0f
+
+ val ms = 1.0f - s
+ val rT = rF * ms
+ val gT = gF * ms
+ val bT = bF * ms
+
+ val m =
+ FloatArray(20) {
+ when (it) {
+ 0 -> (rT + s)
+ 1 -> gT
+ 2 -> bT
+ 5 -> rT
+ 6 -> (gT + s)
+ 7 -> bT
+ 10 -> rT
+ 11 -> gT
+ 12 -> (bT + s)
+ 18 -> 1f
+ else -> 0f
+ }
+ }
+ return m
+ }
+
+ @OptIn(UnstableApi::class)
+ fun createColorMatrix(): ColorMatrix {
+ val matrix = ColorMatrix()
+ val tempMatrix = ColorMatrix()
+
+ if (saturation != COLOR_DEFAULT) {
+ matrix.set(saturationMatrix())
+ }
+ if (hasRgb()) {
+ val colorMatrix = rgbAdjustment().getMatrix(0L, false)
+ val m = FloatArray(20)
+ m[0] = colorMatrix[0]
+ m[1] = colorMatrix[1]
+ m[2] = colorMatrix[2]
+ m[3] = colorMatrix[3]
+ m[4] = 0f
+ m[5] = colorMatrix[4]
+ m[6] = colorMatrix[5]
+ m[7] = colorMatrix[6]
+ m[8] = colorMatrix[7]
+ m[9] = 0f
+ m[10] = colorMatrix[8]
+ m[11] = colorMatrix[9]
+ m[12] = colorMatrix[10]
+ m[13] = colorMatrix[11]
+ m[14] = 0f
+ m[15] = colorMatrix[12]
+ m[16] = colorMatrix[13]
+ m[17] = colorMatrix[14]
+ m[18] = colorMatrix[15]
+ m[19] = 0f
+ tempMatrix.set(m)
+ matrix.postConcat(tempMatrix)
+ }
+ if (hasContrast()) {
+ val scale = contrast / 100.0f
+ tempMatrix.setScale(scale, scale, scale, 1f)
+ matrix.postConcat(tempMatrix)
+ }
+ if (hasBrightness()) {
+ val b = brightness / 100.0f
+ val m = FloatArray(20)
+ m[0] = b
+ m[6] = b
+ m[12] = b
+ m[18] = 1f
+ tempMatrix.set(m)
+ matrix.postConcat(tempMatrix)
+ }
+ // TODO hue
+ // TODO blur
+ return matrix
+ }
+
+ @OptIn(UnstableApi::class)
+ fun createComposeColorMatrix(): androidx.compose.ui.graphics.ColorMatrix {
+ val matrix =
+ androidx.compose.ui.graphics
+ .ColorMatrix()
+
+ if (saturation != COLOR_DEFAULT) {
+ matrix.setToSaturation(saturation / 100f)
+ }
+ if (hasRgb()) {
+ matrix.setToScale(
+ redScale = red / 100f,
+ greenScale = green / 100f,
+ blueScale = blue / 100f,
+ alphaScale = 1f,
+ )
+ }
+ if (hasContrast()) {
+ val scale = contrast / 100.0f
+ val tempMatrix =
+ androidx.compose.ui.graphics
+ .ColorMatrix()
+ tempMatrix.setToScale(scale, scale, scale, 1f)
+ matrix.timesAssign(tempMatrix)
+ }
+ if (hasBrightness()) {
+ val b = brightness / 100.0f
+ val m = FloatArray(20)
+ m[0] = b
+ m[6] = b
+ m[12] = b
+ m[18] = 1f
+ val tempMatrix =
+ androidx.compose.ui.graphics
+ .ColorMatrix(m)
+ matrix.timesAssign(tempMatrix)
+ }
+ // TODO hue
+ // TODO blur
+ return matrix
+ }
+}
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 70e83692..ef3f14e7 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
@@ -186,6 +186,45 @@ sealed interface AppPreference {
summarizer = { value -> value?.toString() },
)
+ val MaxDaysNextUpOptions = listOf(7, 14, 30, 60, 90, 180, 365)
+ val MaxDaysNextUp =
+ AppSliderPreference(
+ title = R.string.max_days_next_up,
+ defaultValue = -1,
+ min = 0,
+ // Max is "no limit" stored as -1
+ max = MaxDaysNextUpOptions.lastIndex + 1L,
+ interval = 1,
+ getter = {
+ MaxDaysNextUpOptions
+ .indexOf(it.homePagePreferences.maxDaysNextUp)
+ .takeIf { it in MaxDaysNextUpOptions.indices }
+ ?.toLong()
+ ?: MaxDaysNextUpOptions.size.toLong()
+ },
+ setter = { prefs, index ->
+ prefs.updateHomePagePreferences {
+ maxDaysNextUp = MaxDaysNextUpOptions.getOrNull(index.toInt()) ?: -1
+ }
+ },
+ summarizer = { value ->
+ if (value != null) {
+ val v = MaxDaysNextUpOptions.getOrNull(value.toInt()) ?: -1
+ if (v == -1) {
+ WholphinApplication.instance.getString(R.string.no_limit)
+ } else {
+ WholphinApplication.instance.resources.getQuantityString(
+ R.plurals.days,
+ v,
+ v.toString(),
+ )
+ }
+ } else {
+ null
+ }
+ },
+ )
+
val CombineContinueNext =
AppSwitchPreference(
title = R.string.combine_continue_next,
@@ -617,6 +656,13 @@ sealed interface AppPreference {
setter = { prefs, _ -> prefs },
)
+ val CustomizeHome =
+ AppDestinationPreference(
+ title = R.string.customize_home,
+ destination = Destination.HomeSettings,
+ summary = R.string.customize_home_summary,
+ )
+
val SendCrashReports =
AppSwitchPreference(
title = R.string.send_crash_reports,
@@ -695,6 +741,18 @@ sealed interface AppPreference {
valueToIndex = { it.number },
)
+ val ManageMedia =
+ AppSwitchPreference(
+ title = R.string.show_media_management,
+ defaultValue = false,
+ getter = { it.interfacePreferences.enableMediaManagement },
+ setter = { prefs, value ->
+ prefs.updateInterfacePreferences { enableMediaManagement = value }
+ },
+ summaryOn = R.string.enabled,
+ summaryOff = R.string.disabled,
+ )
+
val OneClickPause =
AppSwitchPreference(
title = R.string.one_click_pause,
@@ -710,7 +768,7 @@ sealed interface AppPreference {
val SubtitleStyle =
AppDestinationPreference(
title = R.string.subtitle_style,
- destination = Destination.Settings(PreferenceScreenOption.SUBTITLES),
+ destination = Destination.SubtitleSettings(false),
)
val RefreshRateSwitching =
@@ -903,6 +961,60 @@ sealed interface AppPreference {
getter = { },
setter = { prefs, _ -> prefs },
)
+
+ val QuickConnect =
+ AppClickablePreference(
+ title = R.string.quick_connect,
+ summary = R.string.quick_connect_summary,
+ getter = { },
+ setter = { prefs, _ -> prefs },
+ )
+
+ val SlideshowDuration =
+ AppSliderPreference(
+ title = R.string.slideshow_duration,
+ defaultValue = 5_000,
+ min = 1_000,
+ max = 30.seconds.inWholeMilliseconds,
+ interval = 250,
+ getter = {
+ it.photoPreferences.slideshowDuration
+ },
+ setter = { prefs, value ->
+ prefs.updatePhotoPreferences {
+ slideshowDuration = value
+ }
+ },
+ summarizer = { value ->
+ if (value != null) {
+ val seconds = value / 1000.0
+ WholphinApplication.instance.resources.getString(
+ R.string.decimal_seconds,
+ seconds,
+ )
+ } else {
+ null
+ }
+ },
+ )
+
+ val SlideshowPlayVideos =
+ AppSwitchPreference(
+ title = R.string.play_videos_during_slideshow,
+ defaultValue = false,
+ getter = { it.photoPreferences.slideshowPlayVideos },
+ setter = { prefs, value ->
+ prefs.updatePhotoPreferences { slideshowPlayVideos = value }
+ },
+ summaryOn = R.string.enabled,
+ summaryOff = R.string.disabled,
+ )
+
+ val ScreensaverSettings =
+ AppDestinationPreference(
+ title = R.string.screensaver_settings,
+ destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER),
+ )
}
}
@@ -913,13 +1025,11 @@ val basicPreferences =
preferences =
listOf(
AppPreference.SignInAuto,
- AppPreference.HomePageItems,
- AppPreference.CombineContinueNext,
- AppPreference.RewatchNextUp,
AppPreference.PlayThemeMusic,
AppPreference.RememberSelectedTab,
AppPreference.SubtitleStyle,
AppPreference.ThemeColors,
+ AppPreference.ScreensaverSettings,
),
),
PreferenceGroup(
@@ -946,6 +1056,7 @@ val basicPreferences =
preferences =
listOf(
AppPreference.RequireProfilePin,
+ AppPreference.CustomizeHome,
AppPreference.UserPinnedNavDrawerItems,
),
),
@@ -969,8 +1080,6 @@ val basicPreferences =
),
)
-val uiPreferences = listOf()
-
private val ExoPlayerSettings =
listOf(
AppPreference.FfmpegPreference,
@@ -1013,10 +1122,14 @@ val advancedPreferences =
preferences =
listOf(
AppPreference.ShowClock,
+ AppPreference.ManageMedia,
+ AppPreference.CombineContinueNext,
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout,
AppPreference.BackdropStylePref,
+ AppPreference.SlideshowDuration,
+ AppPreference.SlideshowPlayVideos,
),
),
)
@@ -1088,6 +1201,7 @@ val advancedPreferences =
title = R.string.more,
preferences =
listOf(
+ AppPreference.QuickConnect,
AppPreference.SendAppLogs,
AppPreference.SendCrashReports,
AppPreference.DebugLogging,
@@ -1107,6 +1221,24 @@ val liveTvPreferences =
AppPreference.LiveTvColorCodePrograms,
)
+val screensaverPreferences =
+ listOf(
+ PreferenceGroup(
+ title = R.string.screensaver,
+ preferences =
+ listOf(
+ ScreensaverPreference.Enabled,
+ ScreensaverPreference.StartDelay,
+ ScreensaverPreference.Duration,
+ ScreensaverPreference.ShowClock,
+ ScreensaverPreference.Animate,
+ ScreensaverPreference.MaxAge,
+ ScreensaverPreference.ItemTypes,
+ ScreensaverPreference.Start,
+ ),
+ ),
+ )
+
data class AppSwitchPreference(
@get:StringRes override val title: Int,
override val defaultValue: Boolean,
@@ -1161,8 +1293,6 @@ data class AppMultiChoicePreference(
override val getter: (prefs: Pref) -> List,
override val setter: (prefs: Pref, value: List) -> Pref,
@param:StringRes val summary: Int? = null,
- val toSharedPrefs: (T) -> String,
- val fromSharedPrefs: (String) -> T?,
) : AppPreference>
data class AppClickablePreference(
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 f84744c7..bf8ab2a4 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
@@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.google.protobuf.InvalidProtocolBufferException
+import org.jellyfin.sdk.model.api.BaseItemKind
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
@@ -82,6 +83,7 @@ class AppPreferencesSerializer
maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt()
enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue
combineContinueNext = AppPreference.CombineContinueNext.defaultValue
+ maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
}.build()
interfacePreferences =
InterfacePreferences
@@ -100,6 +102,12 @@ class AppPreferencesSerializer
.apply {
resetSubtitles()
}.build()
+ hdrSubtitlesPreferences =
+ SubtitlePreferences
+ .newBuilder()
+ .apply {
+ resetSubtitles()
+ }.build()
liveTvPreferences =
LiveTvPreferences
@@ -113,6 +121,20 @@ class AppPreferencesSerializer
colorCodePrograms =
AppPreference.LiveTvColorCodePrograms.defaultValue
}.build()
+
+ screensaverPreference =
+ ScreensaverPreferences
+ .newBuilder()
+ .apply {
+ startDelay = ScreensaverPreference.DEFAULT_START_DELAY
+ duration = ScreensaverPreference.DEFAULT_DURATION
+ animate = ScreensaverPreference.Animate.defaultValue
+ maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
+ showClock = ScreensaverPreference.ShowClock.defaultValue
+ clearItemTypes()
+ addItemTypes(BaseItemKind.MOVIE.serialName)
+ addItemTypes(BaseItemKind.SERIES.serialName)
+ }.build()
}.build()
advancedPreferences =
@@ -122,6 +144,14 @@ class AppPreferencesSerializer
imageDiskCacheSizeBytes =
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
}.build()
+
+ photoPreferences =
+ PhotoPreferences
+ .newBuilder()
+ .apply {
+ slideshowDuration = AppPreference.SlideshowDuration.defaultValue
+ slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
+ }.build()
}.build()
override suspend fun readFrom(input: InputStream): AppPreferences {
@@ -180,6 +210,16 @@ inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.B
advancedPreferences = advancedPreferences.toBuilder().apply(block).build()
}
+inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder.() -> Unit): AppPreferences =
+ update {
+ photoPreferences = photoPreferences.toBuilder().apply(block).build()
+ }
+
+inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPreferences.Builder.() -> Unit): AppPreferences =
+ updateInterfacePreferences {
+ screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
+ }
+
fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
@@ -193,4 +233,5 @@ fun SubtitlePreferences.Builder.resetSubtitles() {
backgroundStyle = SubtitleSettings.BackgroundStylePref.defaultValue
margin = SubtitleSettings.Margin.defaultValue.toInt()
edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt()
+ imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt()
}
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
new file mode 100644
index 00000000..1e1ad91c
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt
@@ -0,0 +1,183 @@
+package com.github.damontecres.wholphin.preferences
+
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.WholphinApplication
+import org.jellyfin.sdk.model.api.BaseItemKind
+import kotlin.time.Duration.Companion.milliseconds
+
+object ScreensaverPreference {
+ val Enabled =
+ AppSwitchPreference(
+ title = R.string.in_app_screensaver,
+ defaultValue = false,
+ getter = { it.interfacePreferences.screensaverPreference.enabled },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences { enabled = value }
+ },
+ summaryOn = R.string.yes,
+ summaryOff = R.string.no,
+ )
+
+ const val DEFAULT_START_DELAY = 15 * 60_000L
+ private val startDelayValues =
+ listOf(
+ 30_000L,
+ 60_000L,
+ 2 * 60_000L,
+ 5 * 60_000L,
+ 10 * 60_000L,
+ 15 * 60_000L,
+ 30 * 60_000L,
+ 60 * 60_000L,
+ )
+ val StartDelay =
+ AppSliderPreference(
+ title = R.string.start_after,
+ defaultValue = startDelayValues.indexOf(DEFAULT_START_DELAY).toLong(),
+ min = 0,
+ max = startDelayValues.size - 1L,
+ interval = 1,
+ getter = {
+ startDelayValues.indexOf(it.interfacePreferences.screensaverPreference.startDelay).toLong()
+ },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences {
+ startDelay = startDelayValues[value.toInt()]
+ }
+ },
+ summarizer = { value ->
+ if (value != null) {
+ val v = startDelayValues.getOrNull(value.toInt()) ?: DEFAULT_START_DELAY
+ v.milliseconds.toString()
+ } else {
+ null
+ }
+ },
+ )
+
+ const val DEFAULT_DURATION = 30_000L
+ private val durationValues =
+ listOf(
+ 15_000L,
+ 30_000L,
+ 60_000L,
+ 2 * 60_000L,
+ )
+ val Duration =
+ AppSliderPreference(
+ title = R.string.duration,
+ defaultValue = durationValues.indexOf(DEFAULT_DURATION).toLong(),
+ min = 0,
+ max = durationValues.size - 1L,
+ interval = 1,
+ getter = {
+ durationValues.indexOf(it.interfacePreferences.screensaverPreference.duration).toLong()
+ },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences {
+ duration = durationValues[value.toInt()]
+ }
+ },
+ summarizer = { value ->
+ if (value != null) {
+ val v = durationValues.getOrNull(value.toInt()) ?: DEFAULT_DURATION
+ v.milliseconds.toString()
+ } else {
+ null
+ }
+ },
+ )
+
+ val ShowClock =
+ AppSwitchPreference(
+ title = R.string.show_clock,
+ defaultValue = AppPreference.ShowClock.defaultValue,
+ getter = { it.interfacePreferences.screensaverPreference.showClock },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences { showClock = value }
+ },
+ summaryOn = R.string.yes,
+ summaryOff = R.string.no,
+ )
+
+ val Animate =
+ AppSwitchPreference(
+ title = R.string.animate,
+ defaultValue = true,
+ getter = { it.interfacePreferences.screensaverPreference.animate },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences { animate = value }
+ },
+ summaryOn = R.string.enabled,
+ summaryOff = R.string.disabled,
+ )
+
+ const val DEFAULT_MAX_AGE = 16
+ private val maxAgeValues = listOf(0, 5, 10, 13, 14, 16, 18, 21, -1)
+ val MaxAge =
+ AppSliderPreference(
+ title = R.string.max_age_rating,
+ defaultValue = maxAgeValues.indexOf(DEFAULT_MAX_AGE).toLong(),
+ min = 0,
+ max = maxAgeValues.size - 1L,
+ interval = 1,
+ getter = {
+ it.interfacePreferences.screensaverPreference.maxAgeFilter
+ .takeIf { it >= 0 }
+ ?.let { maxAgeValues.indexOf(it).toLong() }
+ ?: maxAgeValues.lastIndex.toLong()
+ },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences {
+ maxAgeFilter = maxAgeValues[value.toInt()]
+ }
+ },
+ summarizer = { value ->
+ when (value) {
+ null -> {
+ null
+ }
+
+ maxAgeValues.lastIndex.toLong() -> {
+ WholphinApplication.instance.getString(R.string.no_max)
+ }
+
+ 0L -> {
+ WholphinApplication.instance.getString(R.string.for_all_ages)
+ }
+
+ else -> {
+ WholphinApplication.instance.getString(
+ R.string.up_to_age,
+ maxAgeValues[value.toInt()].toString(),
+ )
+ }
+ }
+ },
+ )
+
+ val ItemTypes =
+ AppMultiChoicePreference(
+ title = R.string.include_types,
+ summary = R.string.include_types_summary,
+ defaultValue = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
+ allValues = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.PHOTO),
+ displayValues = R.array.screensaver_item_types,
+ getter = {
+ it.interfacePreferences.screensaverPreference.itemTypesList.map { type ->
+ BaseItemKind.fromName(type)
+ }
+ },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences {
+ clearItemTypes()
+ addAllItemTypes(value.map { it.serialName })
+ }
+ },
+ )
+
+ val Start =
+ AppClickablePreference(
+ title = R.string.start_screensaver,
+ )
+}
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 d6fbea40..23d6cef6 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
@@ -8,17 +8,22 @@ import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
+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.updateHomePagePreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions
+import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
+import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
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.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
+import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@@ -213,4 +218,49 @@ suspend fun upgradeApp(
}
}
}
+
+ if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) {
+ appPreferences.updateData {
+ it.updateInterfacePreferences {
+ subtitlesPreferences =
+ subtitlesPreferences
+ .toBuilder()
+ .apply {
+ imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt()
+ }.build()
+ // Copy current subtitle prefs as HDR ones
+ hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build()
+ }
+ }
+ }
+
+ if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) {
+ appPreferences.updateData {
+ it.updatePhotoPreferences {
+ slideshowDuration = AppPreference.SlideshowDuration.defaultValue
+ }
+ }
+ }
+
+ if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) {
+ appPreferences.updateData {
+ it.updateHomePagePreferences {
+ maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
+ }
+ }
+ }
+
+ if (previous.isEqualOrBefore(Version.fromString("0.5.0-6-g0"))) {
+ appPreferences.updateData {
+ it.updateScreensaverPreferences {
+ startDelay = ScreensaverPreference.DEFAULT_START_DELAY
+ duration = ScreensaverPreference.DEFAULT_DURATION
+ animate = ScreensaverPreference.Animate.defaultValue
+ maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
+ clearItemTypes()
+ addItemTypes(BaseItemKind.MOVIE.serialName)
+ addItemTypes(BaseItemKind.SERIES.serialName)
+ }
+ }
+ }
}
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 a64fc9d9..e0c48cca 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
@@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
+import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import javax.inject.Inject
@@ -47,7 +48,12 @@ class BackdropService
suspend fun submit(item: BaseItem) =
withContext(Dispatchers.IO) {
- val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
+ val imageUrl =
+ if (item.type == BaseItemKind.GENRE) {
+ item.imageUrlOverride
+ } else {
+ imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
+ }
submit(item.id.toString(), imageUrl)
}
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
new file mode 100644
index 00000000..b8a850ef
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
@@ -0,0 +1,1024 @@
+package com.github.damontecres.wholphin.services
+
+import android.content.Context
+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.HomePageSettings
+import com.github.damontecres.wholphin.data.model.HomeRowConfig
+import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
+import com.github.damontecres.wholphin.data.model.createGenreDestination
+import com.github.damontecres.wholphin.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.components.getGenreImageMap
+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
+import com.github.damontecres.wholphin.util.GetGenresRequestHandler
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import com.github.damontecres.wholphin.util.GetPersonsHandler
+import com.github.damontecres.wholphin.util.HomeRowLoadingState
+import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error
+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
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.update
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.encodeToStream
+import kotlinx.serialization.json.intOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
+import org.jellyfin.sdk.api.client.extensions.liveTvApi
+import org.jellyfin.sdk.api.client.extensions.userApi
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.api.client.extensions.userViewsApi
+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.ImageType
+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.GetGenresRequest
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
+import org.jellyfin.sdk.model.api.request.GetPersonsRequest
+import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
+import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
+import timber.log.Timber
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class HomeSettingsService
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
+ private val serverRepository: ServerRepository,
+ private val userPreferencesService: UserPreferencesService,
+ private val navDrawerService: NavDrawerService,
+ private val latestNextUpService: LatestNextUpService,
+ private val imageUrlService: ImageUrlService,
+ private val suggestionService: SuggestionService,
+ ) {
+ @OptIn(ExperimentalSerializationApi::class)
+ val jsonParser =
+ Json {
+ isLenient = true
+ ignoreUnknownKeys = true
+ allowTrailingComma = true
+ }
+
+ val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY)
+
+ /**
+ * Saves a [HomePageSettings] to the server for the user under the display preference ID
+ *
+ * @see loadFromServer
+ */
+ suspend fun saveToServer(
+ userId: UUID,
+ settings: HomePageSettings,
+ displayPreferencesId: String = DISPLAY_PREF_ID,
+ ) {
+ val current = getDisplayPreferences(userId, DISPLAY_PREF_ID)
+ val customPrefs =
+ current.customPrefs.toMutableMap().apply {
+ put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings))
+ }
+ api.displayPreferencesApi.updateDisplayPreferences(
+ displayPreferencesId = displayPreferencesId,
+ userId = userId,
+ client = context.getString(R.string.app_name),
+ data = current.copy(customPrefs = customPrefs),
+ )
+ }
+
+ /**
+ * Reads a [HomePageSettings] from the server for the user and display preference ID
+ *
+ * Returns null if there is none saved
+ *
+ * @see saveToServer
+ */
+ suspend fun loadFromServer(
+ userId: UUID,
+ displayPreferencesId: String = DISPLAY_PREF_ID,
+ ): HomePageSettings? {
+ val current = getDisplayPreferences(userId, displayPreferencesId)
+ return current.customPrefs[CUSTOM_PREF_ID]?.let {
+ val jsonElement = jsonParser.parseToJsonElement(it)
+ decode(jsonElement)
+ }
+ }
+
+ private suspend fun getDisplayPreferences(
+ userId: UUID,
+ displayPreferencesId: String,
+ ) = api.displayPreferencesApi
+ .getDisplayPreferences(
+ userId = userId,
+ displayPreferencesId = displayPreferencesId,
+ client = context.getString(R.string.app_name),
+ ).content
+
+ /**
+ * Computes the filename for locally saved [HomePageSettings]
+ */
+ private fun filename(userId: UUID) = "${CUSTOM_PREF_ID}_${userId.toServerString()}.json"
+
+ /**
+ * Save the [HomePageSettings] for the user locally on the device
+ *
+ * @see loadFromLocal
+ */
+ @OptIn(ExperimentalSerializationApi::class)
+ suspend fun saveToLocal(
+ userId: UUID,
+ settings: HomePageSettings,
+ ) {
+ val dir = File(context.filesDir, CUSTOM_PREF_ID)
+ dir.mkdirs()
+ File(dir, filename(userId)).outputStream().use {
+ jsonParser.encodeToStream(settings, it)
+ }
+ }
+
+ /**
+ * Reads [HomePageSettings] for the user if it exists
+ *
+ * @see saveToLocal
+ */
+ @OptIn(ExperimentalSerializationApi::class)
+ suspend fun loadFromLocal(userId: UUID): HomePageSettings? {
+ val dir = File(context.filesDir, CUSTOM_PREF_ID)
+ val file = File(dir, filename(userId))
+ return if (file.exists()) {
+ val fileContents = file.readText()
+ val jsonElement = jsonParser.parseToJsonElement(fileContents)
+ decode(jsonElement)
+ } else {
+ null
+ }
+ }
+
+ /**
+ * Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows
+ *
+ * This is public only for testing
+ */
+ fun decode(element: JsonElement): HomePageSettings {
+ val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull
+ if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) {
+ throw UnsupportedHomeSettingsVersionException(version)
+ }
+ val rowsElement = element.jsonObject["rows"]?.jsonArray
+ val rows =
+ rowsElement
+ ?.mapNotNull { row ->
+ try {
+ jsonParser.decodeFromJsonElement(row)
+ } catch (ex: Exception) {
+ Timber.w(ex, "Unknown row %s", row)
+ // TODO maybe use placeholder instead of null?
+ null
+ }
+ }.orEmpty()
+ return HomePageSettings(rows, version)
+ }
+
+ /**
+ * Loads [HomePageSettings] into [currentSettings]
+ *
+ * First checks locally, then on the server, and finally creates a default if needed
+ *
+ * Does not persist either the server nor default
+ */
+ suspend fun loadCurrentSettings(userId: UUID) {
+ Timber.v("Getting setting for %s", userId)
+ // User local then server/remote otherwise create a default
+ val settings =
+ try {
+ val local = loadFromLocal(userId)
+ Timber.v("Found local? %s", local != null)
+ local
+ } catch (ex: Exception) {
+ Timber.w(ex, "Error loading local settings")
+ // TODO show toast?
+ null
+ } ?: try {
+ val remote = loadFromServer(userId)
+ Timber.v("Found remote? %s", remote != null)
+ remote
+ } catch (ex: Exception) {
+ Timber.w(ex, "Error loading remote settings")
+ null
+ }
+ val resolvedSettings =
+ if (settings != null) {
+ Timber.v("Found settings")
+ // Resolve
+ val resolvedRows =
+ settings.rows.mapIndexed { index, config ->
+ resolve(index, config)
+ }
+ HomePageResolvedSettings(resolvedRows)
+ } else {
+ createDefault(userId)
+ }
+
+ currentSettings.update { resolvedSettings }
+ }
+
+ suspend fun updateCurrent(settings: HomePageSettings) {
+ val resolvedRows =
+ settings.rows.mapIndexed { index, config ->
+ resolve(index, config)
+ }
+ val resolvedSettings = HomePageResolvedSettings(resolvedRows)
+ currentSettings.update { resolvedSettings }
+ }
+
+ /**
+ * Create a default [HomePageResolvedSettings] using the available libraries
+ */
+ suspend fun createDefault(userId: UUID): HomePageResolvedSettings {
+ Timber.v("Creating default settings")
+ val user = serverRepository.currentUser.value?.takeIf { it.id == userId }
+ val userDto = serverRepository.currentUserDto.value?.takeIf { it.id == userId }
+ val libraries =
+ if (user != null) {
+ navDrawerService.getFilteredUserLibraries(user, userDto?.tvAccess ?: false)
+ } else {
+ navDrawerService.getAllUserLibraries(userId, userDto?.tvAccess ?: false)
+ }
+
+ val prefs =
+ userPreferencesService.getCurrent().appPreferences.homePagePreferences
+
+ val includedIds =
+ libraries
+ .mapIndexed { index, it ->
+ val parentId = it.itemId
+ val title = getRecentlyAddedTitle(context, it)
+ if (it.collectionType == CollectionType.LIVETV) {
+ HomeRowConfigDisplay(
+ id = index,
+ title = context.getString(R.string.live_tv),
+ config = HomeRowConfig.TvPrograms(),
+ )
+ } else {
+ HomeRowConfigDisplay(
+ id = index,
+ title = title,
+ config = HomeRowConfig.RecentlyAdded(parentId),
+ )
+ }
+ }
+ val continueWatchingRows =
+ if (prefs.combineContinueNext) {
+ listOf(
+ HomeRowConfigDisplay(
+ id = includedIds.size + 1,
+ title = context.getString(R.string.combine_continue_next),
+ config = HomeRowConfig.ContinueWatchingCombined(),
+ ),
+ )
+ } else {
+ listOf(
+ HomeRowConfigDisplay(
+ id = includedIds.size + 1,
+ title = context.getString(R.string.continue_watching),
+ config = HomeRowConfig.ContinueWatching(),
+ ),
+ HomeRowConfigDisplay(
+ id = includedIds.size + 2,
+ title = context.getString(R.string.next_up),
+ config = HomeRowConfig.NextUp(),
+ ),
+ )
+ }
+ val rowConfig = continueWatchingRows + includedIds
+ return HomePageResolvedSettings(rowConfig)
+ }
+
+ suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
+ val customPrefs =
+ api.displayPreferencesApi
+ .getDisplayPreferences(
+ displayPreferencesId = "usersettings",
+ userId = userId,
+ client = "emby",
+ ).content.customPrefs
+ 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
+ }
+
+ return if (customPrefs.isNotEmpty()) {
+ var id = 0
+ val rowConfigs =
+ (0..9)
+ .mapNotNull { idx ->
+ val sectionType =
+ HomeSectionType.fromString(customPrefs["homesection$idx"]?.lowercase())
+ Timber.v(
+ "sectionType=$sectionType, %s",
+ customPrefs["homesection$idx"]?.lowercase(),
+ )
+ val config =
+ when (sectionType) {
+ HomeSectionType.ACTIVE_RECORDINGS -> {
+ HomeRowConfigDisplay(
+ id = id++,
+ title = context.getString(R.string.active_recordings),
+ config = HomeRowConfig.Recordings(),
+ )
+ }
+
+ HomeSectionType.RESUME -> {
+ HomeRowConfigDisplay(
+ id = id++,
+ title = context.getString(R.string.continue_watching),
+ config = HomeRowConfig.ContinueWatching(),
+ )
+ }
+
+ HomeSectionType.NEXT_UP -> {
+ HomeRowConfigDisplay(
+ id = id++,
+ title = context.getString(R.string.next_up),
+ config = HomeRowConfig.NextUp(),
+ )
+ }
+
+ HomeSectionType.LIVE_TV -> {
+ if (userDto.tvAccess) {
+ HomeRowConfigDisplay(
+ id = id++,
+ title = context.getString(R.string.live_tv),
+ config = HomeRowConfig.TvPrograms(),
+ )
+ } else {
+ null
+ }
+ }
+
+ HomeSectionType.LATEST_MEDIA -> {
+ // Handled below
+ null
+ }
+
+ // Unsupported
+ HomeSectionType.RESUME_AUDIO,
+ HomeSectionType.RESUME_BOOK,
+ -> {
+ null
+ }
+
+ HomeSectionType.SMALL_LIBRARY_TILES,
+ HomeSectionType.LIBRARY_BUTTONS,
+ HomeSectionType.NONE,
+ null,
+ -> {
+ null
+ }
+ }
+ if (sectionType == HomeSectionType.LATEST_MEDIA) {
+ libraries.map {
+ HomeRowConfigDisplay(
+ id = id++,
+ title =
+ context.getString(
+ R.string.recently_added_in,
+ it.name ?: "",
+ ),
+ config = HomeRowConfig.RecentlyAdded(it.id),
+ )
+ }
+ } else if (config != null) {
+ listOf(config)
+ } else {
+ null
+ }
+ }.flatten()
+ HomePageResolvedSettings(rowConfigs)
+ } else {
+ null
+ }
+ }
+
+ /**
+ * Converts a [HomeRowConfig] into [HomeRowConfigDisplay] for UI purposes
+ */
+ suspend fun resolve(
+ id: Int,
+ config: HomeRowConfig,
+ ): HomeRowConfigDisplay =
+ when (config) {
+ is HomeRowConfig.ByParent -> {
+ val name =
+ api.userLibraryApi
+ .getItem(itemId = config.parentId)
+ .content.name ?: ""
+ HomeRowConfigDisplay(
+ id,
+ name,
+ config,
+ )
+ }
+
+ is HomeRowConfig.ContinueWatching -> {
+ HomeRowConfigDisplay(
+ id,
+ context.getString(R.string.continue_watching),
+ config,
+ )
+ }
+
+ is HomeRowConfig.ContinueWatchingCombined -> {
+ HomeRowConfigDisplay(
+ id,
+ context.getString(R.string.combine_continue_next),
+ config,
+ )
+ }
+
+ is HomeRowConfig.Genres -> {
+ val name =
+ api.userLibraryApi
+ .getItem(itemId = config.parentId)
+ .content.name ?: ""
+ HomeRowConfigDisplay(
+ id,
+ context.getString(R.string.genres_in, name),
+ config,
+ )
+ }
+
+ is HomeRowConfig.GetItems -> {
+ HomeRowConfigDisplay(id, config.name, config)
+ }
+
+ is HomeRowConfig.NextUp -> {
+ HomeRowConfigDisplay(
+ id,
+ context.getString(R.string.next_up),
+ config,
+ )
+ }
+
+ is HomeRowConfig.RecentlyAdded -> {
+ val name =
+ api.userLibraryApi
+ .getItem(itemId = config.parentId)
+ .content.name ?: ""
+ HomeRowConfigDisplay(
+ id,
+ context.getString(R.string.recently_added_in, name),
+ config,
+ )
+ }
+
+ is HomeRowConfig.RecentlyReleased -> {
+ val name =
+ api.userLibraryApi
+ .getItem(itemId = config.parentId)
+ .content.name ?: ""
+ HomeRowConfigDisplay(
+ id,
+ context.getString(R.string.recently_released_in, name),
+ config,
+ )
+ }
+
+ is HomeRowConfig.Favorite -> {
+ val name =
+ context.getString(
+ R.string.favorite_items,
+ context.getString(favoriteOptions[config.kind]!!),
+ )
+ HomeRowConfigDisplay(id, name, config)
+ }
+
+ is HomeRowConfig.Recordings -> {
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.active_recordings),
+ config,
+ )
+ }
+
+ is HomeRowConfig.TvPrograms -> {
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.live_tv),
+ config,
+ )
+ }
+
+ is HomeRowConfig.TvChannels -> {
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.channels),
+ config,
+ )
+ }
+
+ is HomeRowConfig.Suggestions -> {
+ val name =
+ api.userLibraryApi
+ .getItem(itemId = config.parentId)
+ .content.name ?: ""
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.suggestions_for, name),
+ config,
+ )
+ }
+ }
+
+ /**
+ * Fetch the data from the server for a given [HomeRowConfig]
+ */
+ suspend fun fetchDataForRow(
+ row: HomeRowConfig,
+ scope: CoroutineScope,
+ prefs: HomePagePreferences,
+ userDto: UserDto,
+ libraries: List,
+ limit: Int = prefs.maxItemsPerRow,
+ isRefresh: Boolean,
+ ): HomeRowLoadingState =
+ when (row) {
+ is HomeRowConfig.ContinueWatching -> {
+ val resume =
+ latestNextUpService.getResume(
+ userDto.id,
+ limit,
+ true,
+ row.viewOptions.useSeries,
+ )
+
+ Success(
+ title = context.getString(R.string.continue_watching),
+ items = resume,
+ viewOptions = row.viewOptions,
+ )
+ }
+
+ is HomeRowConfig.NextUp -> {
+ val nextUp =
+ latestNextUpService.getNextUp(
+ userDto.id,
+ limit,
+ prefs.enableRewatchingNextUp,
+ false,
+ prefs.maxDaysNextUp,
+ row.viewOptions.useSeries,
+ )
+
+ Success(
+ title = context.getString(R.string.next_up),
+ items = nextUp,
+ viewOptions = row.viewOptions,
+ )
+ }
+
+ is HomeRowConfig.ContinueWatchingCombined -> {
+ val resume =
+ latestNextUpService.getResume(
+ userDto.id,
+ limit,
+ true,
+ row.viewOptions.useSeries,
+ )
+ val nextUp =
+ latestNextUpService.getNextUp(
+ userDto.id,
+ limit,
+ prefs.enableRewatchingNextUp,
+ false,
+ prefs.maxDaysNextUp,
+ row.viewOptions.useSeries,
+ )
+
+ Success(
+ title = context.getString(R.string.continue_watching),
+ items =
+ latestNextUpService.buildCombined(
+ resume,
+ nextUp,
+ ),
+ viewOptions = row.viewOptions,
+ )
+ }
+
+ is HomeRowConfig.Genres -> {
+ val request =
+ GetGenresRequest(
+ parentId = row.parentId,
+ userId = userDto.id,
+ limit = limit,
+ )
+ val items =
+ GetGenresRequestHandler
+ .execute(api, request)
+ .content.items
+ val genreIds = items.map { it.id }
+ val genreImages =
+ getGenreImageMap(
+ api = api,
+ userId = serverRepository.currentUser.value?.id,
+ scope = scope,
+ imageUrlService = imageUrlService,
+ genres = genreIds,
+ parentId = row.parentId,
+ includeItemTypes = null,
+ cardWidthPx = null,
+ useCache = isRefresh,
+ )
+ val library =
+ libraries
+ .firstOrNull { it.itemId == row.parentId }
+
+ val title =
+ library?.name?.let { context.getString(R.string.genres_in, it) }
+ ?: context.getString(R.string.genres)
+ val genres =
+ items.map {
+ BaseItem(
+ it,
+ false,
+ genreImages[it.id],
+ createGenreDestination(
+ genreId = it.id,
+ genreName = it.name ?: "",
+ parentId = row.parentId,
+ parentName = library?.name,
+ includeItemTypes =
+ library?.collectionType?.let {
+ getTypeFor(it)?.let {
+ listOf(it)
+ }
+ },
+ ),
+ )
+ }
+
+ Success(
+ title,
+ genres,
+ viewOptions = row.viewOptions,
+ )
+ }
+
+ is HomeRowConfig.RecentlyAdded -> {
+ val library =
+ libraries
+ .firstOrNull { it.itemId == row.parentId }
+ val title = getRecentlyAddedTitle(context, library)
+ val request =
+ GetLatestMediaRequest(
+ fields = SlimItemFields,
+ imageTypeLimit = 1,
+ parentId = row.parentId,
+ groupItems = true,
+ limit = limit,
+ isPlayed = null, // Server will handle user's preference
+ )
+ val latest =
+ api.userLibraryApi
+ .getLatestMedia(request)
+ .content
+ .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ )
+ }
+ latest
+ }
+
+ is HomeRowConfig.RecentlyReleased -> {
+ val name =
+ libraries
+ .firstOrNull { it.itemId == row.parentId }
+ ?.name
+ val title =
+ name?.let {
+ context.getString(R.string.recently_released_in, it)
+ } ?: context.getString(R.string.recently_released)
+ val request =
+ GetItemsRequest(
+ parentId = row.parentId,
+ limit = limit,
+ sortBy = listOf(ItemSortBy.PREMIERE_DATE),
+ sortOrder = listOf(SortOrder.DESCENDING),
+ fields = DefaultItemFields,
+ recursive = true,
+ )
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+
+ is HomeRowConfig.ByParent -> {
+ val request =
+ GetItemsRequest(
+ userId = userDto.id,
+ parentId = row.parentId,
+ recursive = row.recursive,
+ sortBy = row.sort?.let { listOf(it.sort) },
+ sortOrder = row.sort?.let { listOf(it.direction) },
+ limit = limit,
+ fields = DefaultItemFields,
+ )
+ val name =
+ api.userLibraryApi
+ .getItem(itemId = row.parentId)
+ .content.name
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ name ?: context.getString(R.string.collection),
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+
+ is HomeRowConfig.GetItems -> {
+ val request =
+ row.getItems.let {
+ if (it.limit == null) {
+ it.copy(
+ userId = userDto.id,
+ limit = limit,
+ )
+ } else {
+ it.copy(
+ userId = userDto.id,
+ )
+ }
+ }
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ row.name,
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+
+ is HomeRowConfig.Favorite -> {
+ val title =
+ context.getString(
+ R.string.favorite_items,
+ context.getString(favoriteOptions[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,
+ )
+ }
+ } else {
+ val request =
+ GetItemsRequest(
+ userId = userDto.id,
+ recursive = true,
+ limit = limit,
+ fields = DefaultItemFields,
+ includeItemTypes = listOf(row.kind),
+ isFavorite = true,
+ )
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+ }
+
+ is HomeRowConfig.Recordings -> {
+ val request =
+ GetRecordingsRequest(
+ userId = userDto.id,
+ isInProgress = true,
+ fields = DefaultItemFields,
+ limit = limit,
+ enableImages = true,
+ enableUserData = true,
+ )
+ api.liveTvApi
+ .getRecordings(request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ context.getString(R.string.active_recordings),
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+
+ is HomeRowConfig.TvPrograms -> {
+ val request =
+ GetRecommendedProgramsRequest(
+ userId = userDto.id,
+ fields = DefaultItemFields,
+ limit = limit,
+ enableUserData = true,
+ enableImages = true,
+ enableImageTypes = listOf(ImageType.PRIMARY),
+ imageTypeLimit = 1,
+ )
+ api.liveTvApi
+ .getRecommendedPrograms(request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ context.getString(R.string.live_tv),
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+
+ is HomeRowConfig.TvChannels -> {
+ api.liveTvApi
+ .getLiveTvChannels(
+ userId = userDto.id,
+ fields = DefaultItemFields,
+ limit = limit,
+ enableImages = true,
+ ).toBaseItems(api, row.viewOptions.useSeries)
+ .let {
+ Success(
+ context.getString(R.string.channels),
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+
+ is HomeRowConfig.Suggestions -> {
+ val library =
+ api.userLibraryApi
+ .getItem(itemId = row.parentId)
+ .content
+ val title = context.getString(R.string.suggestions_for, library.name ?: "")
+ val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
+ val suggestions =
+ itemKind?.let {
+ suggestionService
+ .getSuggestionsFlow(row.parentId, itemKind)
+ .firstOrNull()
+ }
+ if (suggestions != null && suggestions is SuggestionsResource.Success) {
+ Success(
+ title,
+ suggestions.items,
+ row.viewOptions,
+ )
+ } else if (suggestions is SuggestionsResource.Empty) {
+ Success(
+ title,
+ listOf(),
+ row.viewOptions,
+ )
+ } else {
+ Error(
+ title,
+ message = "Unsupported type ${library.collectionType}",
+ )
+ }
+ }
+ }
+
+ companion object {
+ const val DISPLAY_PREF_ID = "default"
+ const val CUSTOM_PREF_ID = "home_settings"
+ }
+ }
+
+/**
+ * A [HomeRowConfig] with a resolved ID and title so it is usable in the UI
+ */
+data class HomeRowConfigDisplay(
+ val id: Int,
+ val title: String,
+ val config: HomeRowConfig,
+)
+
+/**
+ * List of resolved [HomeRowConfig]s as [HomeRowConfigDisplay]s
+ *
+ * @see HomePageSettings
+ */
+data class HomePageResolvedSettings(
+ val rows: List,
+) {
+ companion object {
+ val EMPTY = HomePageResolvedSettings(listOf())
+ }
+}
+
+// https://github.com/jellyfin/jellyfin/blob/v10.11.6/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/HomeSectionType.cs
+enum class HomeSectionType(
+ val serialName: String,
+) {
+ NONE("none"),
+ SMALL_LIBRARY_TILES("smalllibrarytitles"),
+ LIBRARY_BUTTONS("librarybuttons"),
+ ACTIVE_RECORDINGS("activerecordings"),
+ RESUME("resume"),
+ RESUME_AUDIO("resumeaudio"),
+ LATEST_MEDIA("latestmedia"),
+ NEXT_UP("nextup"),
+ LIVE_TV("livetv"),
+ RESUME_BOOK("resumebook"),
+ ;
+
+ companion object {
+ fun fromString(homeKey: String?) = homeKey?.let { entries.firstOrNull { it.serialName == homeKey } }
+ }
+}
+
+class UnsupportedHomeSettingsVersionException(
+ val unsupportedVersion: Int?,
+ val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
+) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")
+
+fun getRecentlyAddedTitle(
+ context: Context,
+ library: Library?,
+): String =
+ if (library?.isRecordingFolder == true) {
+ context.getString(R.string.recently_recorded)
+ } else {
+ library?.name?.let { context.getString(R.string.recently_added_in, it) }
+ ?: context.getString(R.string.recently_added)
+ }
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 39967e24..1dd642b0 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
@@ -28,15 +28,16 @@ class ImageUrlService
itemType: BaseItemKind,
seriesId: UUID?,
useSeriesForPrimary: Boolean,
- imageTags: Map,
imageType: ImageType,
+ imageTags: Map,
+ backdropTags: List,
+ parentThumbId: UUID? = null,
+ parentBackdropId: UUID? = null,
fillWidth: Int? = null,
fillHeight: Int? = null,
): String? =
when (imageType) {
- ImageType.BACKDROP,
- ImageType.LOGO,
- -> {
+ ImageType.LOGO -> {
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
getItemImageUrl(
itemId = seriesId,
@@ -54,8 +55,86 @@ class ImageUrlService
}
}
+ ImageType.BACKDROP,
+ -> {
+ if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
+ getItemImageUrl(
+ itemId = seriesId,
+ imageType = imageType,
+ fillWidth = fillWidth,
+ fillHeight = fillHeight,
+ )
+ } else if (backdropTags.isNotEmpty()) {
+ getItemImageUrl(
+ itemId = itemId,
+ imageType = imageType,
+ fillWidth = fillWidth,
+ fillHeight = fillHeight,
+ )
+ } else {
+ null
+ }
+ }
+
+ ImageType.THUMB -> {
+ if (useSeriesForPrimary && parentThumbId != null &&
+ (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
+ ) {
+ // Use parent's thumb
+ getItemImageUrl(
+ itemId = parentThumbId,
+ imageType = imageType,
+ fillWidth = fillWidth,
+ fillHeight = fillHeight,
+ )
+ } else if (useSeriesForPrimary && parentBackdropId != null &&
+ (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
+ ) {
+ // No parent thumb, so use backdrop instead
+ getItemImageUrl(
+ itemId = parentBackdropId,
+ imageType = ImageType.BACKDROP,
+ fillWidth = fillWidth,
+ fillHeight = fillHeight,
+ )
+ } else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
+ getItemImageUrl(
+ itemId = parentThumbId,
+ imageType = imageType,
+ fillWidth = fillWidth,
+ fillHeight = fillHeight,
+ )
+ } else if (useSeriesForPrimary &&
+ parentThumbId == null &&
+ itemType == BaseItemKind.EPISODE &&
+ imageType !in imageTags
+ ) {
+ // Workaround to fall back to episode image if no parent thumb
+ getItemImageUrl(
+ itemId = itemId,
+ imageType = ImageType.PRIMARY,
+ fillWidth = fillWidth,
+ fillHeight = fillHeight,
+ )
+ } else if (imageType !in imageTags && backdropTags.isNotEmpty()) {
+ // If no thumb, use backdrop if available
+ getItemImageUrl(
+ itemId = itemId,
+ imageType = ImageType.BACKDROP,
+ fillWidth = fillWidth,
+ fillHeight = fillHeight,
+ )
+ } else {
+ getItemImageUrl(
+ itemId = itemId,
+ imageType = imageType,
+ fillWidth = fillWidth,
+ fillHeight = fillHeight,
+ )
+ }
+ }
+
ImageType.PRIMARY,
- ImageType.THUMB,
ImageType.BANNER,
-> {
if (useSeriesForPrimary && seriesId != null &&
@@ -99,15 +178,19 @@ class ImageUrlService
imageType: ImageType,
fillWidth: Int? = null,
fillHeight: Int? = null,
+ useSeriesForPrimary: Boolean? = null,
): String? =
if (item != null) {
getItemImageUrl(
itemId = item.id,
itemType = item.type,
seriesId = item.data.seriesId,
- useSeriesForPrimary = item.useSeriesForPrimary,
+ useSeriesForPrimary = useSeriesForPrimary ?: item.useSeriesForPrimary,
imageTags = item.data.imageTags.orEmpty(),
imageType = imageType,
+ parentThumbId = item.data.parentThumbItemId,
+ parentBackdropId = item.data.parentBackdropItemId,
+ backdropTags = item.data.backdropImageTags.orEmpty(),
fillWidth = fillWidth,
fillHeight = fillHeight,
)
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 44ed1a58..e30a39ca 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
@@ -4,8 +4,6 @@ import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
-import com.github.damontecres.wholphin.ui.main.LatestData
-import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -21,6 +19,8 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
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.UserDto
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
@@ -44,6 +44,7 @@ class LatestNextUpService
userId: UUID,
limit: Int,
includeEpisodes: Boolean,
+ useSeriesForPrimary: Boolean = true,
): List {
val request =
GetResumeItemsRequest(
@@ -60,13 +61,19 @@ class LatestNextUpService
remove(BaseItemKind.EPISODE)
}
},
+ enableImageTypes =
+ listOf(
+ ImageType.PRIMARY,
+ ImageType.THUMB,
+ ImageType.BACKDROP,
+ ),
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
- .map { BaseItem.from(it, api, true) }
+ .map { BaseItem.from(it, api, useSeriesForPrimary) }
return items
}
@@ -75,7 +82,11 @@ class LatestNextUpService
limit: Int,
enableRewatching: Boolean,
enableResumable: Boolean,
+ maxDays: Int,
+ useSeriesForPrimary: Boolean = true,
): List {
+ val nextUpDateCutoff =
+ maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
val request =
GetNextUpRequest(
userId = userId,
@@ -86,13 +97,14 @@ class LatestNextUpService
enableResumable = enableResumable,
enableUserData = true,
enableRewatching = enableRewatching,
+ nextUpDateCutoff = nextUpDateCutoff,
)
val nextUp =
api.tvShowsApi
.getNextUp(request)
.content
.items
- .map { BaseItem.from(it, api, true) }
+ .map { BaseItem.from(it, api, useSeriesForPrimary) }
return nextUp
}
@@ -188,3 +200,17 @@ class LatestNextUpService
return@withContext result
}
}
+
+val supportedLatestCollectionTypes =
+ setOf(
+ CollectionType.MOVIES,
+ CollectionType.TVSHOWS,
+ CollectionType.HOMEVIDEOS,
+ // Exclude Live TV because a recording folder view will be used instead
+ null, // Recordings & mixed collection types
+ )
+
+data class LatestData(
+ val title: String,
+ val request: GetLatestMediaRequest,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt
new file mode 100644
index 00000000..a13881a9
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt
@@ -0,0 +1,110 @@
+package com.github.damontecres.wholphin.services
+
+import android.content.Context
+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.ui.launchIO
+import com.github.damontecres.wholphin.ui.showToast
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.libraryApi
+import org.jellyfin.sdk.model.api.BaseItemKind
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MediaManagementService
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
+ private val serverRepository: ServerRepository,
+ private val userPreferencesService: UserPreferencesService,
+ ) {
+ private val _deletedItemFlow =
+ MutableSharedFlow(
+ replay = 1,
+ extraBufferCapacity = 0,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+
+ /**
+ * Listen for recently deleted items. Useful for ViewModels to react and refresh data
+ */
+ val deletedItemFlow: SharedFlow = _deletedItemFlow
+
+ suspend fun canDelete(item: BaseItem): Boolean {
+ val appPreferences = userPreferencesService.getCurrent().appPreferences
+ return canDelete(item, appPreferences)
+ }
+
+ fun canDelete(
+ item: BaseItem,
+ appPreferences: AppPreferences,
+ ): Boolean {
+ Timber.v("canDelete %s: %s", item.id, item.canDelete)
+ val enabled = appPreferences.interfacePreferences.enableMediaManagement
+ return enabled &&
+ item.canDelete &&
+ if (item.type == BaseItemKind.RECORDING) {
+ serverRepository.currentUserDto.value
+ ?.policy
+ ?.enableLiveTvManagement == true
+ } else {
+ true
+ }
+ }
+
+ suspend fun deleteItem(item: BaseItem): DeleteResult {
+ try {
+ Timber.i("Deleting %s", item.id)
+ // TODO enable
+ api.libraryApi.deleteItem(item.id)
+ _deletedItemFlow.emit(DeletedItem(item))
+ return DeleteResult.Success
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error deleting %s", item.id)
+ return DeleteResult.Error(ex)
+ }
+ }
+ }
+
+data class DeletedItem(
+ val item: BaseItem,
+)
+
+sealed interface DeleteResult {
+ data object Success : DeleteResult
+
+ data class Error(
+ val ex: Exception,
+ ) : DeleteResult
+}
+
+fun ViewModel.deleteItem(
+ context: Context,
+ mediaManagementService: MediaManagementService,
+ item: BaseItem,
+ onSuccess: () -> Unit = {},
+) = viewModelScope.launchIO {
+ when (val r = mediaManagementService.deleteItem(item)) {
+ is DeleteResult.Error -> {
+ showToast(
+ context,
+ "Error deleting item: ${r.ex.localizedMessage}",
+ )
+ }
+
+ DeleteResult.Success -> {
+ showToast(context, "Deleted")
+ onSuccess.invoke()
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt
new file mode 100644
index 00000000..14a93ba2
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt
@@ -0,0 +1,80 @@
+package com.github.damontecres.wholphin.services
+
+import android.content.Context
+import android.os.Build
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
+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.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.serialization.json.Json
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.clientLogApi
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.model.ClientInfo
+import org.jellyfin.sdk.model.DeviceInfo
+import org.jellyfin.sdk.model.UUID
+import org.jellyfin.sdk.model.api.BaseItemDto
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MediaReportService
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
+ private val serverRepository: ServerRepository,
+ private val userPreferencesService: UserPreferencesService,
+ private val clientInfo: ClientInfo,
+ private val deviceInfo: DeviceInfo,
+ private val deviceProfileService: DeviceProfileService,
+ @param:IoCoroutineScope private val ioScope: CoroutineScope,
+ ) {
+ val json =
+ Json {
+ encodeDefaults = false
+ }
+
+ fun sendReportFor(itemId: UUID) {
+ ioScope.launchIO(ExceptionHandler(autoToast = true)) {
+ val item = api.userLibraryApi.getItem(itemId = itemId).content
+ sendReportFor(item)
+ }
+ }
+
+ suspend fun sendReportFor(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 serverVersion = serverRepository.currentServer.value?.serverVersion
+ val deviceProfile =
+ deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion)
+ val deviceProfileJson = json.encodeToString(deviceProfile)
+ val body =
+ """
+ Send media info
+ serverVersion=$serverVersion
+ clientInfo=$clientInfo
+ 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)
+ showToast(context, "Sent! Filename=${response.fileName}")
+ }
+ }
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
new file mode 100644
index 00000000..b83f0bd0
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt
@@ -0,0 +1,194 @@
+package com.github.damontecres.wholphin.services
+
+import android.content.Context
+import androidx.lifecycle.asFlow
+import com.github.damontecres.wholphin.data.ServerPreferencesDao
+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.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.supportedCollectionTypes
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+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.extensions.liveTvApi
+import org.jellyfin.sdk.api.client.extensions.userViewsApi
+import org.jellyfin.sdk.model.api.CollectionType
+import org.jellyfin.sdk.model.api.UserDto
+import timber.log.Timber
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class NavDrawerService
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ @param:DefaultCoroutineScope private val coroutineScope: CoroutineScope,
+ private val api: ApiClient,
+ private val serverRepository: ServerRepository,
+ private val serverPreferencesDao: ServerPreferencesDao,
+ private val seerrServerRepository: SeerrServerRepository,
+ ) {
+ private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
+ val state: StateFlow = _state
+
+ init {
+ serverRepository.currentUser
+ .asFlow()
+ .combine(serverRepository.currentUserDto.asFlow()) { 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(),
+ )
+ }
+ if (user != null && userDto != null && user.id == userDto.id) {
+ updateNavDrawer(user, userDto)
+ }
+ }.catch { ex ->
+ Timber.e(ex, "Error updating nav drawer")
+ showToast(context, "Error fetching user's views")
+ }.launchIn(coroutineScope)
+
+ seerrServerRepository.active
+ .onEach { discoverActive ->
+ _state.update { it.copy(discoverEnabled = discoverActive) }
+ }.launchIn(coroutineScope)
+ }
+
+ suspend fun getAllUserLibraries(
+ userId: UUID,
+ tvAccess: Boolean,
+ ): List {
+ val userViews =
+ api.userViewsApi
+ .getUserViews(userId = userId)
+ .content.items
+ val recordingFolders =
+ if (tvAccess) {
+ api.liveTvApi
+ .getRecordingFolders(userId = userId)
+ .content.items
+ .map { it.id }
+ .toSet()
+ } else {
+ setOf()
+ }
+ val libraries =
+ userViews
+ .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
+ .map {
+ Library(
+ itemId = it.id,
+ name = it.name ?: "",
+ type = it.type,
+ collectionType = it.collectionType ?: CollectionType.UNKNOWN,
+ isRecordingFolder = it.id in recordingFolders,
+ )
+ }
+ return libraries
+ }
+
+ suspend fun getFilteredUserLibraries(
+ user: JellyfinUser,
+ tvAccess: Boolean,
+ ): List {
+ val pins =
+ serverPreferencesDao
+ .getNavDrawerPinnedItems(user)
+ .associateBy { it.itemId }
+ val libraries =
+ getAllUserLibraries(user.id, tvAccess)
+ .filterNot { pins[ServerNavDrawerItem.getId(it.itemId)]?.type == NavPinType.UNPINNED }
+ return libraries
+ }
+
+ suspend fun updateNavDrawer(
+ user: JellyfinUser,
+ userDto: UserDto,
+ ) {
+ val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
+ val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess)
+ val libraries =
+ allLibraries
+ .map {
+ val destination =
+ if (it.isRecordingFolder) {
+ Destination.Recordings(it.itemId)
+ } else {
+ Destination.MediaItem(
+ it.itemId,
+ it.type,
+ it.collectionType,
+ )
+ }
+ ServerNavDrawerItem(
+ itemId = it.itemId,
+ name = it.name,
+ destination = destination,
+ type = it.collectionType,
+ )
+ }
+ val allItems = builtins + libraries
+
+ val navDrawerPins =
+ withContext(Dispatchers.IO) {
+ serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
+ }
+
+ val items = mutableListOf()
+ val moreItems = mutableListOf()
+ allItems
+ // Sort by order if non-default, existing items before customize will have -1 value
+ // New items from the server will get Int.MAX_VALUE
+ // Items the user doesn't have access to anymore will be skipped
+ .sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE }
+ .forEach {
+ // Assume pinned if unknown
+ val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED
+ if (pinned == NavPinType.PINNED) {
+ items.add(it)
+ } else {
+ moreItems.add(it)
+ }
+ }
+
+ _state.update {
+ it.copy(
+ items = items,
+ moreItems = moreItems,
+ )
+ }
+ }
+ }
+
+data class NavDrawerItemState(
+ val items: List,
+ val moreItems: List,
+ val discoverEnabled: Boolean,
+) {
+ companion object {
+ val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false)
+ }
+}
+
+val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt
index b4967b03..6a35dda5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt
@@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.services
+import android.content.Context
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.letNotEmpty
+import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import javax.inject.Inject
@@ -12,6 +14,7 @@ import javax.inject.Singleton
class PeopleFavorites
@Inject
constructor(
+ @param:ApplicationContext private val context: Context,
private val api: ApiClient,
) {
suspend fun getPeopleFor(item: BaseItem): List =
@@ -27,7 +30,14 @@ class PeopleFavorites
val people =
item.data.people
?.letNotEmpty { people ->
- people.map { Person.fromDto(it, favorites[it.id] ?: false, api) }
+ people.map {
+ Person.fromDto(
+ context,
+ it,
+ favorites[it.id] ?: false,
+ api,
+ )
+ }
}.orEmpty()
people
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt
index 3c32a497..4be082a5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt
@@ -21,7 +21,10 @@ class PlaybackLifecycleObserver
override fun onStart(owner: LifecycleOwner) {
val lastDest = navigationManager.backStack.lastOrNull()
- if (lastDest is Destination.Playback || lastDest is Destination.PlaybackList) {
+ if (lastDest is Destination.Playback ||
+ lastDest is Destination.PlaybackList ||
+ lastDest is Destination.Slideshow
+ ) {
navigationManager.goBack()
}
wasPlaying = null
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 be6e461e..06d116a4 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
@@ -6,17 +6,17 @@ import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.Display
-import androidx.lifecycle.LiveData
-import com.github.damontecres.wholphin.ui.setValueOnMain
+import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.ui.showToast
-import com.github.damontecres.wholphin.util.EqualityMutableLiveData
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
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import timber.log.Timber
-import java.util.concurrent.CountDownLatch
-import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.roundToInt
@@ -28,25 +28,6 @@ class RefreshRateService
constructor(
@param:ApplicationContext private val context: Context,
) {
- private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
- private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
- private val originalMode = display.mode
-
- val supportedDisplayModes get() = display.supportedModes.orEmpty()
-
- private val displayModes: List by lazy {
- display.supportedModes
- .orEmpty()
- .map { DisplayMode(it) }
- .sortedWith(
- compareByDescending({ it.physicalWidth * it.physicalHeight })
- .thenBy { it.refreshRateRounded },
- )
- }
-
- private val _refreshRateMode = EqualityMutableLiveData(originalMode.modeId)
- val refreshRateMode: LiveData = _refreshRateMode
-
/**
* Find the best display mode for the given stream and signal to change to it
*/
@@ -54,11 +35,23 @@ class RefreshRateService
stream: MediaStream,
switchRefreshRate: Boolean,
switchResolution: Boolean,
- ) {
+ ) = withContext(Dispatchers.IO) {
if (!switchRefreshRate && !switchResolution) {
Timber.v("Not switching either refresh rate nor resolution")
- return
+ return@withContext
}
+ val displayManager =
+ MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
+ val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
+ val displayModes =
+ display.supportedModes
+ .orEmpty()
+ .map { DisplayMode(it) }
+ .sortedWith(
+ compareByDescending({ it.physicalWidth * it.physicalHeight })
+ .thenBy { it.refreshRateRounded },
+ )
+
val currentDisplayMode = display.mode
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
val width = stream.width
@@ -67,7 +60,7 @@ class RefreshRateService
if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate
if (width == null || height == null || frameRate == null) {
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate)
- return
+ return@withContext
}
Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
val targetMode =
@@ -86,14 +79,20 @@ class RefreshRateService
listener,
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
)
- _refreshRateMode.setValueOnMain(targetMode.modeId)
try {
- if (!listener.latch.await(5, TimeUnit.SECONDS)) {
+ MainActivity.instance.changeDisplayMode(targetMode.modeId)
+ val result =
+ withTimeoutOrNull(5.seconds) {
+ listener.deferred.await()
+ }
+ if (result == null) {
Timber.w("Timed out waiting for display change")
showToast(context, "Refresh rate switch is taking a long time")
}
- } catch (ex: InterruptedException) {
+ } catch (ex: Exception) {
Timber.w(ex, "Exception waiting for refresh rate switch")
+ } finally {
+ displayManager.unregisterDisplayListener(listener)
}
val targetRate = (targetMode.refreshRate * 1000).roundToInt()
val isSeamless =
@@ -110,7 +109,6 @@ class RefreshRateService
// Wait the recommended 2 seconds (https://developer.android.com/media/optimize/performance/frame-rate)
delay(2.seconds)
}
- displayManager.unregisterDisplayListener(listener)
}
}
@@ -118,13 +116,13 @@ class RefreshRateService
* Reset the display mode to the original
*/
fun resetRefreshRate() {
- _refreshRateMode.value = originalMode.modeId
+ MainActivity.instance.changeDisplayMode(0)
}
private class Listener(
val displayId: Int,
) : DisplayManager.DisplayListener {
- val latch = CountDownLatch(1)
+ val deferred = CompletableDeferred()
override fun onDisplayAdded(displayId: Int) {
}
@@ -132,7 +130,7 @@ class RefreshRateService
override fun onDisplayChanged(displayId: Int) {
if (displayId == this.displayId) {
Timber.v("Got display change for $displayId")
- latch.countDown()
+ deferred.complete(Unit)
}
}
@@ -160,33 +158,58 @@ class RefreshRateService
if (refreshRateSwitch) {
displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
- .filter {
- it.refreshRateRounded % streamRate == 0 || // Exact multiple
- it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
- }
+ .filter { frameRateMatches(it.refreshRateRounded, streamRate) }
} else {
displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
}
// Timber.v("display modes candidates: %s", candidates.joinToString("\n"))
return if (!resolutionSwitch) {
- candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
+ candidates
+ .filter { it.refreshRateRounded == streamRate }
+ .maxByOrNull { it.physicalWidth * it.physicalHeight }
+ ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
} else {
+ // Exact resolution & frame rate
candidates.firstOrNull {
it.physicalWidth == streamWidth &&
it.physicalHeight == streamHeight &&
it.refreshRateRounded == streamRate
}
- ?: candidates.firstOrNull {
- it.physicalWidth == streamWidth &&
+ // Next highest resolution, exact frame rate
+ ?: candidates.lastOrNull {
+ it.physicalWidth >= streamWidth &&
it.physicalHeight >= streamHeight &&
it.refreshRateRounded == streamRate
}
+ // Exact resolution, acceptable frame rate
+ ?: candidates.lastOrNull {
+ it.physicalWidth == streamWidth &&
+ it.physicalHeight == streamHeight &&
+ frameRateMatches(it.refreshRateRounded, streamRate)
+ }
+ // Next highest resolution, acceptable frame rate
+ ?: candidates.lastOrNull {
+ it.physicalWidth >= streamWidth &&
+ it.physicalHeight >= streamHeight &&
+ frameRateMatches(it.refreshRateRounded, streamRate)
+ }
+ // Fall back to largest resolution, exact frame rate
?: candidates
.filter { it.refreshRateRounded == streamRate }
.maxByOrNull { it.physicalWidth * it.physicalHeight }
+ // Finally, just the highest resolution
+ ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
}
}
+
+ fun frameRateMatches(
+ refreshRateRounded: Int,
+ streamRate: Int,
+ ): Boolean {
+ return refreshRateRounded % streamRate == 0 || // Exact multiple
+ refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
+ }
}
}
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
new file mode 100644
index 00000000..5d73ec21
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt
@@ -0,0 +1,218 @@
+package com.github.damontecres.wholphin.services
+
+import android.content.Context
+import coil3.imageLoader
+import coil3.request.ImageRequest
+import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
+import com.github.damontecres.wholphin.ui.components.CurrentItem
+import com.github.damontecres.wholphin.ui.formatDate
+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 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
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.cancellable
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.libraryApi
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.api.ItemSortBy
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration.Companion.milliseconds
+
+@Singleton
+class ScreensaverService
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ @param:DefaultCoroutineScope private val scope: CoroutineScope,
+ private val api: ApiClient,
+ private val userPreferencesService: UserPreferencesService,
+ private val imageUrlService: ImageUrlService,
+ ) {
+ private val _state = MutableStateFlow(ScreensaverState(false, false, false, false))
+ val state: StateFlow = _state
+
+ val keepScreenOn = MutableStateFlow(false)
+
+ private var waitJob: Job? = null
+
+ init {
+ userPreferencesService.flow
+ .onEach { prefs ->
+ _state.update {
+ val enabled =
+ prefs.appPreferences.interfacePreferences.screensaverPreference.enabled
+ keepScreenOnInternal(enabled)
+ ScreensaverState(enabled, false, false, false)
+ }
+ }.launchIn(scope)
+ }
+
+ fun pulse() {
+ waitJob?.cancel()
+ if (_state.value.enabled) {
+// Timber.v("pulse")
+ _state.update {
+ if (!it.active) {
+ it.copy(active = false)
+ } else {
+ it
+ }
+ }
+
+ if (!_state.value.paused) {
+ waitJob =
+ scope.launch(ExceptionHandler()) {
+ val startDelay =
+ userPreferencesService
+ .getCurrent()
+ .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
+ delay(startDelay)
+ _state.update {
+ it.copy(active = true)
+ }
+ }
+ }
+ }
+ }
+
+ fun start() {
+ _state.update {
+ it.copy(
+ enabledTemp = true,
+ active = true,
+ )
+ }
+ }
+
+ fun stop(cancelJob: Boolean) {
+ _state.update {
+ it.copy(
+ enabledTemp = false,
+ active = false,
+ )
+ }
+ if (cancelJob) waitJob?.cancel()
+ }
+
+ fun keepScreenOn(keep: Boolean) {
+ scope.launchDefault {
+ val screensaverEnabled = _state.value.enabled
+ Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled)
+ if (screensaverEnabled) {
+ // Page is requesting to keep screen on, so we don't wait to show the screensaver
+ _state.update {
+ it.copy(active = false, paused = keep)
+ }
+ if (!keep) {
+ pulse()
+ }
+ } else {
+ keepScreenOnInternal(keep)
+ }
+ }
+ }
+
+ private fun keepScreenOnInternal(keep: Boolean) {
+ keepScreenOn.update { keep }
+ }
+
+ fun createItemFlow(scope: CoroutineScope): Flow =
+ flow {
+ val prefs =
+ userPreferencesService.flow
+ .first()
+ .appPreferences
+ .interfacePreferences.screensaverPreference
+ val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 }
+ val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) }
+ val request =
+ GetItemsRequest(
+ recursive = true,
+ includeItemTypes = itemTypes,
+ imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP),
+ sortBy = listOf(ItemSortBy.RANDOM),
+ maxOfficialRating = maxAge?.toString(),
+ hasParentalRating = maxAge?.let { true },
+ )
+ val pager =
+ ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
+ Timber.v("Got %s items", pager.size)
+ var index = 0
+ if (pager.isEmpty()) {
+ emit(null)
+ } else {
+ while (true) {
+ val item = pager.getBlocking(index)
+ Timber.v("Next index=%s, item=%s", index, item?.id)
+ if (item != null) {
+ val backdropUrl =
+ if (item.type == BaseItemKind.PHOTO) {
+ api.libraryApi.getDownloadUrl(item.id)
+ } else {
+ imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
+ }
+ val title =
+ if (item.type == BaseItemKind.PHOTO) {
+ item.data.premiereDate?.let {
+ formatDate(it.toLocalDate())
+ }
+ } else {
+ item.title
+ }
+ val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO)
+ if (backdropUrl != null) {
+ try {
+ context.imageLoader
+ .enqueue(
+ ImageRequest
+ .Builder(context)
+ .data(backdropUrl)
+ .build(),
+ ).job
+ .await()
+ emit(CurrentItem(item, backdropUrl, logoUrl, title ?: ""))
+ } catch (_: CancellationException) {
+ break
+ }
+ val duration =
+ userPreferencesService
+ .getCurrent()
+ .appPreferences
+ .interfacePreferences.screensaverPreference.duration.milliseconds
+ delay(duration)
+ }
+ }
+ index++
+ }
+ }
+ }.flowOn(Dispatchers.Default).cancellable()
+ }
+
+data class ScreensaverState(
+ val enabled: Boolean,
+ val enabledTemp: Boolean,
+ val active: Boolean,
+ val paused: Boolean,
+) {
+ val show get() = (enabled || enabledTemp) && active && !paused
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
index f06a7529..970e80c3 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
@@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.api.seerr.model.PublicSettings
import com.github.damontecres.wholphin.api.seerr.model.User
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.SeerrServer
@@ -24,8 +25,10 @@ import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.supervisorScope
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
@@ -44,18 +47,33 @@ class SeerrServerRepository
private val serverRepository: ServerRepository,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
- private val _current = MutableStateFlow(null)
- val current: StateFlow = _current
- val currentServer: Flow = current.map { it?.server }
- val currentUser: Flow = current.map { it?.user }
+ private val _connection =
+ MutableStateFlow(SeerrConnectionStatus.NotConfigured)
+ val connection: StateFlow = _connection
+
+ val current: Flow =
+ _connection.map { (it as? SeerrConnectionStatus.Success)?.current }
+ val currentServer: Flow =
+ connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
+ val currentUser: Flow =
+ connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
/**
* Whether Seerr integration is currently active of not
*/
- val active: Flow = current.map { it != null && seerrApi.active }
+ val active: Flow =
+ connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
fun clear() {
- _current.update { null }
+ _connection.update { SeerrConnectionStatus.NotConfigured }
+ seerrApi.update("", null)
+ }
+
+ fun error(
+ serverUrl: String,
+ exception: Exception,
+ ) {
+ _connection.update { SeerrConnectionStatus.Error(serverUrl, exception) }
seerrApi.update("", null)
}
@@ -65,8 +83,10 @@ class SeerrServerRepository
userConfig: SeerrUserConfig,
) {
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
- _current.update {
- CurrentSeerr(server, user, userConfig, publicSettings)
+ _connection.update {
+ SeerrConnectionStatus.Success(
+ CurrentSeerr(server, user, userConfig, publicSettings),
+ )
}
}
@@ -145,7 +165,7 @@ class SeerrServerRepository
apiKey,
okHttpClient
.newBuilder()
- .connectTimeout(5.seconds)
+ .connectTimeout(2.seconds)
.readTimeout(6.seconds)
.build(),
)
@@ -153,10 +173,11 @@ class SeerrServerRepository
return LoadingState.Success
}
- suspend fun removeServer() {
- val current = _current.value ?: return
- seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
+ suspend fun removeServerForCurrentUser(): Boolean {
+ val current = current.firstOrNull() ?: return false
+ val rows = seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
clear()
+ return rows > 0
}
}
@@ -165,6 +186,19 @@ class SeerrServerRepository
*/
typealias SeerrUserConfig = User
+sealed interface SeerrConnectionStatus {
+ data object NotConfigured : SeerrConnectionStatus
+
+ data class Error(
+ val serverUrl: String,
+ val ex: Exception,
+ ) : SeerrConnectionStatus
+
+ data class Success(
+ val current: CurrentSeerr,
+ ) : SeerrConnectionStatus
+}
+
data class CurrentSeerr(
val server: SeerrServer,
val user: SeerrUser,
@@ -226,6 +260,7 @@ class UserSwitchListener
private val seerrServerRepository: SeerrServerRepository,
private val seerrServerDao: SeerrServerDao,
private val seerrApi: SeerrApi,
+ private val homeSettingsService: HomeSettingsService,
) {
init {
context as AppCompatActivity
@@ -233,43 +268,58 @@ class UserSwitchListener
serverRepository.currentUser.asFlow().collect { user ->
Timber.d("New user")
seerrServerRepository.clear()
+ homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
if (user != null) {
- seerrServerDao
- .getUsersByJellyfinUser(user.rowId)
- .firstOrNull()
- ?.let { seerrUser ->
- val server = seerrServerDao.getServer(seerrUser.serverId)?.server
- if (server != null) {
- Timber.i("Found a seerr user & server")
- seerrApi.update(server.url, seerrUser.credential)
- val userConfig =
- if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
- try {
- login(
- seerrApi.api,
- seerrUser.authMethod,
- seerrUser.username,
- seerrUser.password,
- )
- } catch (ex: Exception) {
- Timber.w(ex, "Error logging into %s", server.url)
- seerrServerRepository.clear()
- return@let
- }
- } else {
- try {
- seerrApi.api.usersApi.authMeGet()
- } catch (ex: Exception) {
- Timber.w(ex, "Error logging into %s", server.url)
- seerrServerRepository.clear()
- return@let
- }
- }
- seerrServerRepository.set(server, seerrUser, userConfig)
- }
- }
+ switchUser(user)
}
}
}
}
+
+ private suspend fun switchUser(user: JellyfinUser) =
+ supervisorScope {
+ // Check for home settings
+ launchIO {
+ homeSettingsService.loadCurrentSettings(user.id)
+ }
+ // Check for seerr server
+ launchIO {
+ seerrServerDao
+ .getUsersByJellyfinUser(user.rowId)
+ .lastOrNull()
+ ?.let { seerrUser ->
+ val server =
+ seerrServerDao.getServer(seerrUser.serverId)?.server
+ if (server != null) {
+ Timber.i("Found a seerr user & server")
+ try {
+ seerrApi.update(server.url, seerrUser.credential)
+ val userConfig =
+ if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
+ login(
+ seerrApi.api,
+ seerrUser.authMethod,
+ seerrUser.username,
+ seerrUser.password,
+ )
+ } else {
+ seerrApi.api.usersApi.authMeGet()
+ }
+ seerrServerRepository.set(
+ server,
+ seerrUser,
+ userConfig,
+ )
+ } catch (ex: Exception) {
+ Timber.w(
+ ex,
+ "Error logging into %s",
+ server.url,
+ )
+ seerrServerRepository.error(server.url, ex)
+ }
+ }
+ }
+ }
+ }
}
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 bd95ea1d..e4910031 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
@@ -17,7 +17,7 @@ import javax.inject.Singleton
class SetupNavigationManager
@Inject
constructor() {
- var backStack: MutableList = mutableStateListOf(SetupDestination.Loading)
+ var backStack: MutableList = mutableStateListOf(SetupDestination.Loading)
/**
* Go to the specified [SetupDestination]
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt
new file mode 100644
index 00000000..0fe32179
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt
@@ -0,0 +1,100 @@
+package com.github.damontecres.wholphin.services
+
+import androidx.lifecycle.asFlow
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ItemFields
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import timber.log.Timber
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+sealed interface SuggestionsResource {
+ data object Loading : SuggestionsResource
+
+ data class Success(
+ val items: List,
+ ) : SuggestionsResource
+
+ data object Empty : SuggestionsResource
+}
+
+@Singleton
+class SuggestionService
+ @Inject
+ constructor(
+ private val api: ApiClient,
+ private val serverRepository: ServerRepository,
+ private val cache: SuggestionsCache,
+ private val workManager: WorkManager,
+ ) {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun getSuggestionsFlow(
+ parentId: UUID,
+ itemKind: BaseItemKind,
+ ): Flow {
+ return serverRepository.currentUser
+ .asFlow()
+ .flatMapLatest { user ->
+ val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
+ val cachedSuggestions = cache.get(userId, parentId, itemKind)
+ if (cachedSuggestions == null) {
+ workManager
+ .getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
+ .map { workInfos ->
+ val isActive =
+ workInfos.any {
+ it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
+ }
+ if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
+ }
+ } else if (cachedSuggestions.ids.isEmpty()) {
+ flowOf(SuggestionsResource.Empty)
+ } else {
+ flow {
+ try {
+ emit(
+ SuggestionsResource.Success(
+ fetchItemsByIds(cachedSuggestions.ids, itemKind),
+ ),
+ )
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to fetch items")
+ emit(SuggestionsResource.Empty)
+ }
+ }
+ }
+ }
+ }
+
+ private suspend fun fetchItemsByIds(
+ ids: List,
+ itemKind: BaseItemKind,
+ ): List {
+ val isSeries = itemKind == BaseItemKind.SERIES
+ val request =
+ GetItemsRequest(
+ ids = ids,
+ fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW),
+ )
+ return GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem.from(it, api, isSeries) }
+ }
+ }
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
new file mode 100644
index 00000000..0921a1be
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt
@@ -0,0 +1,108 @@
+@file:UseSerializers(UUIDSerializer::class)
+
+package com.github.damontecres.wholphin.services
+
+import android.content.Context
+import com.github.damontecres.wholphin.ui.toServerString
+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
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.UseSerializers
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.decodeFromStream
+import kotlinx.serialization.json.encodeToStream
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.serializer.UUIDSerializer
+import timber.log.Timber
+import java.io.File
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Serializable
+data class CachedSuggestions(
+ val ids: List,
+)
+
+@Singleton
+class SuggestionsCache
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ ) {
+ private val cacheDir: File
+ get() = File(context.cacheDir, "suggestions")
+ private val json = Json { ignoreUnknownKeys = true }
+ private val mutex = Mutex()
+
+ private val memoryCache =
+ InMemoryKache(maxSize = 8) {
+ creationScope = CoroutineScope(Dispatchers.IO)
+ }
+
+ private fun cacheKey(
+ userId: UUID,
+ libraryId: UUID,
+ itemKind: BaseItemKind,
+ ) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}"
+
+ @OptIn(ExperimentalSerializationApi::class)
+ suspend fun get(
+ userId: UUID,
+ libraryId: UUID,
+ itemKind: BaseItemKind,
+ ): CachedSuggestions? {
+ val key = cacheKey(userId, libraryId, itemKind)
+ return memoryCache.getOrPut(key) {
+ mutex.withLock {
+ try {
+ val cacheFile = File(cacheDir, "$key.json")
+ if (!cacheFile.exists()) {
+ return@withLock null
+ }
+
+ cacheFile.inputStream().use {
+ json.decodeFromStream(it)
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Exception reading from disk cache")
+ null
+ }
+ }
+ }
+ }
+
+ @OptIn(ExperimentalSerializationApi::class)
+ suspend fun put(
+ userId: UUID,
+ libraryId: UUID,
+ itemKind: BaseItemKind,
+ ids: List,
+ ) {
+ val key = cacheKey(userId, libraryId, itemKind)
+ val suggestions = CachedSuggestions(ids)
+ memoryCache.put(key, suggestions)
+ try {
+ cacheDir.mkdirs()
+ mutex.withLock {
+ File(cacheDir, "$key.json").outputStream().use {
+ json.encodeToStream(suggestions, it)
+ }
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Exception writing to disk cache")
+ }
+ }
+ }
+
+fun ObjectKache<*, *>.isEmpty(): Boolean = this.size == 0L
+
+fun ObjectKache<*, *>.isNotEmpty(): Boolean = !isEmpty()
+
+suspend fun ObjectKache.containsKey(key: T): Boolean = get(key) != null
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt
new file mode 100644
index 00000000..86bab2f3
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt
@@ -0,0 +1,122 @@
+package com.github.damontecres.wholphin.services
+
+import android.content.Context
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.util.ExceptionHandler
+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 kotlinx.coroutines.withContext
+import timber.log.Timber
+import java.util.UUID
+import javax.inject.Inject
+import kotlin.random.Random
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.toJavaDuration
+
+@ActivityScoped
+class SuggestionsSchedulerService
+ @Inject
+ constructor(
+ @param:ActivityContext private val context: Context,
+ private val serverRepository: ServerRepository,
+ private val workManager: WorkManager,
+ ) {
+ private val activity =
+ (context as? AppCompatActivity)
+ ?: throw IllegalStateException(
+ "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
+ )
+
+ // Exposed for testing
+ internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
+ internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) }
+
+ init {
+ serverRepository.current.observe(activity) { user ->
+ Timber.v("New user %s", user?.user?.id)
+ if (user == null) {
+ workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
+ } else {
+ activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
+ scheduleWork(user.user.id, user.server.id)
+ }
+ }
+ }
+ }
+
+ private suspend fun scheduleWork(
+ userId: UUID,
+ serverId: UUID,
+ ) {
+ val workInfos =
+ withContext(dispatcher) {
+ workManager
+ .getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME)
+ .get()
+ }
+ val activeWork =
+ workInfos.firstOrNull {
+ !it.state.isFinished
+ }
+ val scheduledUserId =
+ activeWork
+ ?.tags
+ ?.firstOrNull { it.startsWith("user:") }
+ ?.removePrefix("user:")
+
+ val isAlreadyScheduledForUser = scheduledUserId == userId.toString()
+ if (isAlreadyScheduledForUser) {
+ Timber.d("SuggestionsWorker already scheduled for user %s", userId)
+ return
+ }
+
+ val constraints =
+ Constraints
+ .Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+
+ val inputData =
+ workDataOf(
+ SuggestionsWorker.PARAM_USER_ID to userId.toString(),
+ SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(),
+ )
+
+ val periodicWorkRequestBuilder =
+ PeriodicWorkRequestBuilder(
+ repeatInterval = 12.hours.toJavaDuration(),
+ flexTimeInterval = 1.hours.toJavaDuration(),
+ ).setConstraints(constraints)
+ .setBackoffCriteria(
+ BackoffPolicy.EXPONENTIAL,
+ 15.minutes.toJavaDuration(),
+ ).setInputData(inputData)
+ .addTag("user:$userId")
+
+ val initialDelaySeconds = initialDelaySecondsProvider().coerceIn(60L, 180L)
+ periodicWorkRequestBuilder.setInitialDelay(initialDelaySeconds.seconds.toJavaDuration())
+
+ Timber.i("Scheduling periodic SuggestionsWorker with ${initialDelaySeconds}s delay")
+
+ workManager.enqueueUniquePeriodicWork(
+ uniqueWorkName = SuggestionsWorker.WORK_NAME,
+ existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE,
+ request = periodicWorkRequestBuilder.build(),
+ )
+ }
+ }
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
new file mode 100644
index 00000000..4560f2d9
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt
@@ -0,0 +1,281 @@
+package com.github.damontecres.wholphin.services
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+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 dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.supervisorScope
+import kotlinx.coroutines.withContext
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.exception.ApiClientException
+import org.jellyfin.sdk.api.client.extensions.userViewsApi
+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.api.ItemFields
+import org.jellyfin.sdk.model.api.ItemSortBy
+import org.jellyfin.sdk.model.api.SortOrder
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import org.jellyfin.sdk.model.serializer.toUUIDOrNull
+import timber.log.Timber
+import java.util.UUID
+import kotlin.coroutines.CoroutineContext
+
+private val BaseItemDto.relevantId: UUID get() = seriesId ?: id
+
+@HiltWorker
+class SuggestionsWorker
+ @AssistedInject
+ constructor(
+ @Assisted private val context: Context,
+ @Assisted workerParams: WorkerParameters,
+ private val serverRepository: ServerRepository,
+ private val preferences: DataStore,
+ private val api: ApiClient,
+ private val cache: SuggestionsCache,
+ ) : CoroutineWorker(context, workerParams) {
+ override suspend fun doWork(): Result {
+ Timber.d("Start")
+ val serverId = inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure()
+ val userId = inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure()
+
+ if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
+ var currentUser = serverRepository.current.value
+ if (currentUser == null) {
+ serverRepository.restoreSession(serverId, userId)
+ currentUser = serverRepository.current.value
+ }
+ if (currentUser == null) {
+ Timber.w("No user found during run")
+ return Result.failure()
+ }
+ }
+
+ try {
+ val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
+ val itemsPerRow =
+ prefs.homePagePreferences.maxItemsPerRow
+ .takeIf { it > 0 }
+ ?: AppPreference.HomePageItems.defaultValue.toInt()
+
+ val views =
+ api.userViewsApi
+ .getUserViews(userId = userId)
+ .content.items
+ .orEmpty()
+ if (views.isEmpty()) {
+ return Result.success()
+ }
+ val results =
+ supervisorScope {
+ val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
+ views
+ .mapNotNull { view ->
+ val itemKind =
+ getTypeForCollection(view.collectionType)
+ ?: return@mapNotNull null
+ async(Dispatchers.IO) {
+ runCatching {
+ Timber.v("Fetching suggestions for view %s", view.id)
+ val suggestions =
+ fetchSuggestions(
+ context,
+ view.id,
+ userId,
+ itemKind,
+ itemsPerRow,
+ )
+ ensureActive()
+ val newIds = suggestions.map { it.id }
+ val cachedIds = cache.get(userId, view.id, itemKind)?.ids
+ if (cachedIds == newIds) {
+ Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id)
+ return@runCatching
+ }
+ cache.put(
+ userId,
+ view.id,
+ itemKind,
+ newIds,
+ )
+ }.onFailure { e ->
+ Timber.e(
+ e,
+ "Failed to fetch suggestions for view %s",
+ view.id,
+ )
+ }
+ }
+ }.awaitAll()
+ }
+ val successCount = results.count { it.isSuccess }
+ val failureCount = results.count { it.isFailure }
+ if (failureCount > 0 && successCount == 0) {
+ Timber.w("All attempts failed ($failureCount views), scheduling retry")
+ return Result.retry()
+ }
+ Timber.d("Completed with $successCount successes and $failureCount failures")
+ return Result.success()
+ } catch (ex: ApiClientException) {
+ Timber.w(ex, "SuggestionsWorker ApiClientException, will retry")
+ return Result.retry()
+ } catch (e: Exception) {
+ Timber.e(e, "SuggestionsWorker failed")
+ return Result.failure()
+ }
+ }
+
+ private suspend fun fetchSuggestions(
+ coroutineContext: CoroutineContext,
+ parentId: UUID,
+ userId: UUID,
+ itemKind: BaseItemKind,
+ itemsPerRow: Int,
+ ): List =
+ coroutineScope {
+ val isSeries = itemKind == BaseItemKind.SERIES
+ val historyItemType = if (isSeries) BaseItemKind.EPISODE else itemKind
+
+ val contextualLimit = (itemsPerRow * 0.4).toInt().coerceAtLeast(1)
+ val randomLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
+ val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
+
+ val historyDeferred =
+ async(coroutineContext) {
+ fetchItems(
+ parentId = parentId,
+ userId = userId,
+ itemKind = historyItemType,
+ sortBy = ItemSortBy.DATE_PLAYED,
+ isPlayed = true,
+ limit = 20,
+ extraFields = listOf(ItemFields.GENRES),
+ ).distinctBy { it.relevantId }.take(3)
+ }
+
+ val seedItems = historyDeferred.await()
+
+ val allGenreIds =
+ seedItems
+ .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
+ .distinct()
+
+ val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
+
+ val contextualDeferred =
+ async(coroutineContext) {
+ if (allGenreIds.isEmpty()) {
+ emptyList()
+ } else {
+ fetchItems(
+ parentId = parentId,
+ userId = userId,
+ itemKind = itemKind,
+ sortBy = ItemSortBy.RANDOM,
+ isPlayed = false,
+ limit = contextualLimit,
+ genreIds = allGenreIds,
+ excludeItemIds = excludeIds.toList(),
+ )
+ }
+ }
+
+ val randomDeferred =
+ async(coroutineContext) {
+ fetchItems(
+ parentId = parentId,
+ userId = userId,
+ itemKind = itemKind,
+ sortBy = ItemSortBy.RANDOM,
+ isPlayed = false,
+ limit = randomLimit,
+ )
+ }
+
+ val freshDeferred =
+ async(coroutineContext) {
+ fetchItems(
+ parentId = parentId,
+ userId = userId,
+ itemKind = itemKind,
+ sortBy = ItemSortBy.DATE_CREATED,
+ sortOrder = SortOrder.DESCENDING,
+ isPlayed = false,
+ limit = freshLimit,
+ )
+ }
+ withContext(Dispatchers.Default) {
+ val contextual = contextualDeferred.await()
+ val random = randomDeferred.await()
+ val fresh = freshDeferred.await()
+
+ (contextual + fresh + random)
+ .asSequence()
+ .distinctBy { it.id }
+ .filterNot { excludeIds.contains(it.relevantId) }
+ .toList()
+ .shuffled()
+ .take(itemsPerRow)
+ }
+ }
+
+ private suspend fun fetchItems(
+ parentId: UUID,
+ userId: UUID,
+ itemKind: BaseItemKind,
+ sortBy: ItemSortBy,
+ isPlayed: Boolean,
+ limit: Int,
+ sortOrder: SortOrder? = null,
+ genreIds: List? = null,
+ excludeItemIds: List? = null,
+ extraFields: List = emptyList(),
+ ): List {
+ val request =
+ GetItemsRequest(
+ parentId = parentId,
+ userId = userId,
+ fields = extraFields,
+ includeItemTypes = listOf(itemKind),
+ genreIds = genreIds,
+ recursive = true,
+ isPlayed = isPlayed,
+ excludeItemIds = excludeItemIds,
+ sortBy = listOf(sortBy),
+ sortOrder = sortOrder?.let { listOf(it) },
+ limit = limit,
+ enableTotalRecordCount = false,
+ imageTypeLimit = 0,
+ )
+ return GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .orEmpty()
+ }
+
+ companion object {
+ const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
+ const val PARAM_USER_ID = "userId"
+ const val PARAM_SERVER_ID = "serverId"
+
+ fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? =
+ when (collectionType) {
+ CollectionType.MOVIES -> BaseItemKind.MOVIE
+ CollectionType.TVSHOWS -> BaseItemKind.SERIES
+ else -> null
+ }
+ }
+ }
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 90d68e52..67e940b5 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
@@ -5,6 +5,7 @@ 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 javax.inject.Inject
import javax.inject.Singleton
@@ -15,6 +16,8 @@ class UserPreferencesService
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore,
) {
+ val flow = preferencesDataStore.data.map { UserPreferences(it) }
+
suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
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 e6ae832a..991a8e98 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
@@ -2,6 +2,7 @@ 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
@@ -9,6 +10,7 @@ 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 dagger.Module
@@ -16,6 +18,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
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
@@ -43,6 +46,18 @@ annotation class StandardOkHttpClient
@Retention(AnnotationRetention.BINARY)
annotation class IoCoroutineScope
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class DefaultCoroutineScope
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class IoDispatcher
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class DefaultDispatcher
+
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@@ -56,12 +71,6 @@ object AppModule {
version = BuildConfig.VERSION_NAME,
)
- @Provides
- @Singleton
- fun deviceInfo(
- @ApplicationContext context: Context,
- ): DeviceInfo = androidDevice(context)
-
@StandardOkHttpClient
@Provides
@Singleton
@@ -106,12 +115,12 @@ object AppModule {
@Singleton
fun okHttpFactory(
@StandardOkHttpClient okHttpClient: OkHttpClient,
- ) = OkHttpFactory(okHttpClient)
+ ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient))
@Provides
@Singleton
fun jellyfin(
- okHttpFactory: OkHttpFactory,
+ okHttpFactory: CoroutineContextApiClientFactory,
@ApplicationContext context: Context,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
@@ -171,10 +180,35 @@ object AppModule {
}
}
+ @Provides
+ @Singleton
+ @IoDispatcher
+ fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
+
@Provides
@Singleton
@IoCoroutineScope
- fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ fun ioCoroutineScope(
+ @IoDispatcher dispatcher: CoroutineDispatcher,
+ ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
+
+ @Provides
+ @Singleton
+ @DefaultDispatcher
+ fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
+
+ @Provides
+ @Singleton
+ @DefaultCoroutineScope
+ fun defaultCoroutineScope(
+ @DefaultDispatcher dispatcher: CoroutineDispatcher,
+ ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
+
+ @Provides
+ @Singleton
+ fun workManager(
+ @ApplicationContext context: Context,
+ ): WorkManager = WorkManager.getInstance(context)
@Provides
@Singleton
@@ -182,3 +216,13 @@ object AppModule {
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = SeerrApi(okHttpClient)
}
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DeviceModule {
+ @Provides
+ @Singleton
+ fun deviceInfo(
+ @ApplicationContext context: Context,
+ ): DeviceInfo = androidDevice(context)
+}
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 ed3f761b..31c92774 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
@@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao
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.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
@@ -66,6 +67,10 @@ object DatabaseModule {
@Singleton
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
+ @Provides
+ @Singleton
+ fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
+
@Provides
@Singleton
fun userPreferencesDataStore(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt
index 05ce1d2a..297cbd64 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt
@@ -21,6 +21,7 @@ import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
@ActivityScoped
@@ -29,9 +30,9 @@ class TvProviderSchedulerService
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
+ private val workManager: WorkManager,
) {
private val activity = (context as AppCompatActivity)
- private val workManager = WorkManager.getInstance(context)
private val supportsTvProvider =
// TODO <=25 has limited support
@@ -60,7 +61,8 @@ class TvProviderSchedulerService
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
),
- ).build(),
+ ).setInitialDelay(60.seconds.toJavaDuration())
+ .build(),
).await()
}
}
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 134e4903..03ea0478 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
@@ -80,7 +80,7 @@ class TvProviderWorker
getPotentialItems(
userId,
prefs.homePagePreferences.enableRewatchingNextUp,
- prefs.homePagePreferences.combineContinueNext,
+ prefs.homePagePreferences.maxDaysNextUp,
)
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
@@ -144,19 +144,15 @@ class TvProviderWorker
private suspend fun getPotentialItems(
userId: UUID,
enableRewatching: Boolean,
- combineContinueNext: Boolean,
+ maxDaysNextUp: Int,
): List {
val resumeItems = latestNextUpService.getResume(userId, 10, true)
val seriesIds = resumeItems.mapNotNull { it.data.seriesId }
val nextUpItems =
latestNextUpService
- .getNextUp(userId, 10, enableRewatching, false)
+ .getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
- return if (combineContinueNext) {
- latestNextUpService.buildCombined(resumeItems, nextUpItems)
- } else {
- resumeItems + nextUpItems
- }
+ return latestNextUpService.buildCombined(resumeItems, nextUpItems)
}
private suspend fun getCurrentTvChannelNextUp(): List =
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 12854cd7..865c6d29 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
@@ -5,7 +5,6 @@ import android.content.Context
import android.content.ContextWrapper
import android.media.AudioManager
import android.view.KeyEvent
-import android.view.WindowManager
import android.widget.Toast
import androidx.compose.foundation.MarqueeAnimationMode
import androidx.compose.foundation.basicMarquee
@@ -46,7 +45,6 @@ import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
-import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID
@@ -296,7 +294,7 @@ fun Arrangement.spacedByWithFooter(space: Dp) =
}
/**
- * Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn].
+ * Tries to find the [Activity] for the given [Context]
*/
fun Context.findActivity(): Activity? {
if (this is Activity) {
@@ -310,18 +308,6 @@ fun Context.findActivity(): Activity? {
return null
}
-/**
- * Keep the screen on for an [Activity]. Often used with [findActivity].
- */
-fun Activity.keepScreenOn(keep: Boolean) {
- Timber.v("Keep screen on: $keep")
- if (keep) {
- window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- } else {
- window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- }
-}
-
/**
* Selectively log errors from Coil image loading.
*
@@ -391,6 +377,15 @@ fun CoroutineScope.launchIO(
block: suspend CoroutineScope.() -> Unit,
): Job = launch(context = Dispatchers.IO + context, start = start, block = block)
+/**
+ * Launches a coroutine with [Dispatchers.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)
+
/**
* Converts a UUID to the format used server-side (ie without hyphens).
*
@@ -433,12 +428,6 @@ fun Response.toBaseItems(
useSeriesForPrimary: Boolean,
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
-@Composable
-fun rememberBackDropImage(item: BaseItem): String? {
- val imageUrlService = LocalImageUrlService.current
- return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) }
-}
-
/**
* Check if this, coalescing nulls to zero, is greater than that
*/
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 e4f107b4..8f4066bb 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
@@ -5,6 +5,7 @@ import androidx.compose.foundation.text.appendInlineContent
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 org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber
@@ -76,7 +77,7 @@ val BaseItemDto.seriesProductionYears: String?
append(productionYear.toString())
if (status == "Continuing") {
append(" - ")
- append("Present")
+ append(WholphinApplication.instance.getString(R.string.series_continueing))
} else if (status == "Ended") {
endDate?.let {
if (it.year != productionYear) {
@@ -167,19 +168,19 @@ fun listToDotString(
strings.forEachIndexed { index, string ->
append(string)
if (index != strings.lastIndex) dot()
- communityRating?.let {
- dot()
- append(String.format(Locale.getDefault(), "%.1f", it))
- appendInlineContent(id = "star")
- }
- criticRating?.let {
- dot()
- append("${it.toInt()}%")
- if (it >= 60f) {
- appendInlineContent(id = "fresh")
- } else {
- appendInlineContent(id = "rotten")
- }
+ }
+ communityRating?.let {
+ dot()
+ append(String.format(Locale.getDefault(), "%.1f", it))
+ appendInlineContent(id = "star")
+ }
+ criticRating?.let {
+ dot()
+ append("${it.toInt()}%")
+ if (it >= 60f) {
+ appendInlineContent(id = "fresh")
+ } else {
+ appendInlineContent(id = "rotten")
}
}
}
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 54f564f1..ab0f69f8 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
@@ -57,6 +57,8 @@ val DefaultItemFields =
ItemFields.CHAPTERS,
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_SOURCE_COUNT,
+ ItemFields.PARENT_ID,
+ ItemFields.CAN_DELETE,
)
/**
@@ -70,11 +72,22 @@ val SlimItemFields =
ItemFields.OVERVIEW,
ItemFields.SORT_NAME,
ItemFields.MEDIA_SOURCE_COUNT,
+ ItemFields.PARENT_ID,
+ ItemFields.CAN_DELETE,
)
+val PhotoItemFields =
+ DefaultItemFields +
+ listOf(
+ ItemFields.WIDTH,
+ ItemFields.HEIGHT,
+ )
+
object Cards {
- val height2x3 = 172.dp
- val heightEpisode = height2x3 * .75f
+ const val HEIGHT_2X3_DP = 172
+ val height2x3 = HEIGHT_2X3_DP.dp
+ const val HEIGHT_EPISODE = 128
+ 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 70de419b..51d003fe 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,15 +1,19 @@
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.Row
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.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -24,6 +28,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
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.Dp
import androidx.compose.ui.unit.dp
@@ -41,6 +46,7 @@ import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService
+import com.github.damontecres.wholphin.ui.enableMarquee
import org.jellyfin.sdk.model.api.ImageType
/**
@@ -60,6 +66,9 @@ fun BannerCard(
cardHeight: Dp = 120.dp,
aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource? = null,
+ imageType: ImageType = ImageType.PRIMARY,
+ imageContentScale: ContentScale = ContentScale.FillBounds,
+ useSeriesForPrimary: Boolean = true,
) {
val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
@@ -74,19 +83,21 @@ fun BannerCard(
}
}
val imageUrl =
- remember(item, fillHeight) {
+ remember(item, fillHeight, imageType, useSeriesForPrimary) {
if (item != null) {
- imageUrlService.getItemImageUrl(
- item,
- ImageType.PRIMARY,
- fillWidth = null,
- fillHeight = fillHeight,
- )
+ item.imageUrlOverride
+ ?: imageUrlService.getItemImageUrl(
+ item,
+ imageType,
+ fillWidth = null,
+ fillHeight = fillHeight,
+ useSeriesForPrimary = useSeriesForPrimary,
+ )
} else {
null
}
}
- var imageError by remember { mutableStateOf(false) }
+ var imageError by remember(imageUrl) { mutableStateOf(false) }
Card(
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
onClick = onClick,
@@ -107,7 +118,7 @@ fun BannerCard(
AsyncImage(
model = imageUrl,
contentDescription = null,
- contentScale = ContentScale.FillBounds,
+ contentScale = imageContentScale,
onError = { imageError = true },
modifier = Modifier.fillMaxSize(),
)
@@ -115,7 +126,7 @@ fun BannerCard(
Text(
text = name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
- style = MaterialTheme.typography.titleLarge,
+ style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier =
Modifier
@@ -181,3 +192,84 @@ fun BannerCard(
}
}
}
+
+@Composable
+fun BannerCardWithTitle(
+ title: String?,
+ subtitle: String?,
+ item: BaseItem?,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ cornerText: String? = null,
+ played: Boolean = false,
+ favorite: Boolean = false,
+ playPercent: Double = 0.0,
+ cardHeight: Dp = 120.dp,
+ aspectRatio: Float = AspectRatios.WIDE,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ imageType: ImageType = ImageType.PRIMARY,
+ imageContentScale: ContentScale = ContentScale.FillBounds,
+ 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) 4.dp else 12.dp)
+ val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
+ val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
+ val width = cardHeight * aspectRationToUse
+ Column(
+ verticalArrangement = Arrangement.spacedBy(spaceBetween),
+ modifier = modifier.width(width),
+ ) {
+ BannerCard(
+ name = null,
+ item = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier = Modifier,
+ cornerText = cornerText,
+ played = played,
+ favorite = favorite,
+ playPercent = playPercent,
+ cardHeight = cardHeight,
+ aspectRatio = aspectRatio,
+ interactionSource = interactionSource,
+ imageType = imageType,
+ imageContentScale = imageContentScale,
+ useSeriesForPrimary = useSeriesForPrimary,
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ modifier =
+ Modifier
+ .padding(bottom = spaceBelow)
+ .fillMaxWidth(),
+ ) {
+ Text(
+ text = title ?: "",
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ textAlign = TextAlign.Center,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp)
+ .enableMarquee(focusedAfterDelay),
+ )
+ Text(
+ text = subtitle ?: "",
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Normal,
+ maxLines = 1,
+ textAlign = TextAlign.Center,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp)
+ .enableMarquee(focusedAfterDelay),
+ )
+ }
+ }
+}
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 cf799c54..b1e64a2a 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
@@ -34,6 +34,7 @@ fun ChapterRow(
text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.padding(start = 8.dp),
)
LazyRow(
state = rememberLazyListState(),
@@ -41,7 +42,6 @@ fun ChapterRow(
contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
modifier =
Modifier
- .padding(start = 16.dp)
.fillMaxWidth()
.focusRestorer(),
) {
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 4e1d0e86..a7df0cb3 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
@@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.pluralStringResource
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.Dp
import androidx.compose.ui.unit.dp
@@ -183,6 +184,8 @@ fun DiscoverItemCard(
text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@@ -193,6 +196,8 @@ fun DiscoverItemCard(
text = item?.releaseDate?.year?.toString() ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
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
index e4ffa5ee..279ca7b5 100644
--- 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
@@ -22,11 +22,13 @@ 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.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
@@ -130,6 +132,8 @@ fun EpisodeCard(
text = dto?.seriesName ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@@ -140,6 +144,8 @@ fun EpisodeCard(
text = item?.name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
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 73cf5ba3..a32fece9 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
@@ -42,11 +42,29 @@ fun GenreCard(
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) = GenreCard(
+ genreId = genre?.id,
+ name = genre?.name,
+ imageUrl = genre?.imageUrl,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier = modifier,
+ interactionSource = interactionSource,
+)
+
+@Composable
+fun GenreCard(
+ genreId: UUID?,
+ name: String?,
+ imageUrl: String?,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
- val background = rememberIdColor(genre?.id).copy(alpha = .6f)
+ val background = rememberIdColor(genreId).copy(alpha = .6f)
Card(
- modifier =
- modifier,
+ modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
@@ -63,12 +81,12 @@ fun GenreCard(
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
) {
- if (genre?.imageUrl.isNotNullOrBlank()) {
+ if (imageUrl != null) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
- .data(genre.imageUrl)
+ .data(imageUrl)
.crossfade(true)
.build(),
contentScale = ContentScale.FillBounds,
@@ -88,7 +106,7 @@ fun GenreCard(
.background(background),
) {
Text(
- text = genre?.name ?: "",
+ text = name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
@@ -112,7 +130,6 @@ private fun GenreCardPreview() {
UUID.randomUUID(),
"Adventure",
null,
- Color.Black,
)
GenreCard(
genre = genre,
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 7b653865..c191a70d 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
@@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -113,6 +114,8 @@ fun GridCard(
text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
@@ -124,6 +127,8 @@ fun GridCard(
text = item?.subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt
index 83166fa7..314a7e1a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt
@@ -5,6 +5,7 @@ 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.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -57,6 +58,7 @@ fun ItemRow(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.padding(start = 8.dp),
)
LazyRow(
state = state,
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 47fd4e94..2ce33e09 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
@@ -26,6 +26,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
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.dp
import androidx.compose.ui.unit.sp
@@ -174,6 +175,8 @@ fun PersonCard(
text = name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@@ -185,6 +188,8 @@ fun PersonCard(
text = role,
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
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 2895db83..140cc2fd 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
@@ -46,6 +46,7 @@ fun PersonRow(
text = stringResource(title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.padding(start = 8.dp),
)
LazyRow(
state = rememberLazyListState(),
@@ -53,7 +54,6 @@ fun PersonRow(
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
- .padding(start = 16.dp)
.fillMaxWidth()
.focusRestorer(firstFocus),
) {
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 e3e6059e..b066d372 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
@@ -16,15 +16,16 @@ 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.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.AspectRatios
@@ -126,21 +127,7 @@ fun SeasonCard(
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 focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = imageHeight * aspectRationToUse
val height = imageWidth * (1f / aspectRationToUse)
@@ -193,6 +180,8 @@ fun SeasonCard(
text = title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@@ -203,6 +192,8 @@ fun SeasonCard(
text = subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
@@ -212,3 +203,22 @@ fun SeasonCard(
}
}
}
+
+/**
+ * Returns a [androidx.compose.runtime.State] which represents if the item has been focused for a while
+ */
+@Composable
+fun rememberFocusedAfterDelay(interactionSource: MutableInteractionSource): androidx.compose.runtime.State {
+ val focused by interactionSource.collectIsFocusedAsState()
+ val state = remember { mutableStateOf(false) }
+
+ LaunchedEffect(focused) {
+ if (!focused) {
+ state.value = false
+ return@LaunchedEffect
+ }
+ delay(500L)
+ state.value = true
+ }
+ return state
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt
new file mode 100644
index 00000000..d26675b1
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt
@@ -0,0 +1,215 @@
+package com.github.damontecres.wholphin.ui.components
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+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.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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.geometry.center
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RadialGradientShader
+import androidx.compose.ui.graphics.Shader
+import androidx.compose.ui.graphics.ShaderBrush
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.datastore.core.DataStore
+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 coil3.annotation.ExperimentalCoilApi
+import coil3.compose.AsyncImage
+import coil3.compose.useExistingImageAsPlaceholder
+import coil3.request.ImageRequest
+import coil3.request.transitionFactory
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.services.ScreensaverService
+import com.github.damontecres.wholphin.ui.AppColors
+import com.github.damontecres.wholphin.ui.CrossFadeFactory
+import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA
+import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+
+@HiltViewModel
+class ScreensaverViewModel
+ @Inject
+ constructor(
+ private val screensaverService: ScreensaverService,
+ val preferencesDataStore: DataStore,
+ ) : ViewModel() {
+ val currentItem = screensaverService.createItemFlow(viewModelScope)
+ }
+
+data class CurrentItem(
+ val item: BaseItem,
+ val backdropUrl: String,
+ val logoUrl: String?,
+ val title: String,
+)
+
+@Composable
+fun AppScreensaver(
+ prefs: AppPreferences,
+ modifier: Modifier = Modifier,
+ viewModel: ScreensaverViewModel = hiltViewModel(),
+) {
+ val currentItem by viewModel.currentItem.collectAsState(null)
+ AppScreensaverContent(
+ currentItem = currentItem,
+ showClock = prefs.interfacePreferences.screensaverPreference.showClock,
+ duration = prefs.interfacePreferences.screensaverPreference.duration.milliseconds,
+ animate = prefs.interfacePreferences.screensaverPreference.animate,
+ modifier = modifier,
+ )
+}
+
+@OptIn(ExperimentalCoilApi::class)
+@Composable
+fun AppScreensaverContent(
+ currentItem: CurrentItem?,
+ showClock: Boolean,
+ duration: Duration,
+ animate: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier
+ .background(Color.Black),
+ ) {
+ val infiniteTransition = rememberInfiniteTransition()
+ val scale by infiniteTransition.animateFloat(
+ 1f,
+ if (animate) 1.1f else 1f,
+ infiniteRepeatable(
+ tween(
+ durationMillis = duration.inWholeMilliseconds.toInt(),
+ delayMillis = 500,
+ easing = LinearEasing,
+ ),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ )
+ AsyncImage(
+ model =
+ ImageRequest
+ .Builder(LocalContext.current)
+ .data(currentItem?.backdropUrl)
+ .transitionFactory(CrossFadeFactory(2000.milliseconds))
+ .useExistingImageAsPlaceholder(true)
+ .build(),
+ contentDescription = null,
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ },
+ )
+
+ var logoError by remember(currentItem) { mutableStateOf(false) }
+ val alignment = listOf(Alignment.BottomStart, Alignment.BottomEnd).random()
+ if (!logoError) {
+ AsyncImage(
+ model =
+ ImageRequest
+ .Builder(LocalContext.current)
+ .data(currentItem?.logoUrl)
+ .transitionFactory(CrossFadeFactory(750.milliseconds))
+ .build(),
+ contentDescription = "Logo",
+ onError = {
+ logoError = true
+ },
+ modifier =
+ Modifier
+ .align(alignment)
+ .size(width = 240.dp, height = 120.dp)
+ .padding(16.dp),
+ )
+ } else {
+ Box(
+ modifier =
+ Modifier
+ .align(alignment)
+ .padding(16.dp)
+ .fillMaxWidth(.5f)
+ .fillMaxHeight(.3f),
+ ) {
+ Text(
+ text = currentItem?.title ?: "",
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.displaySmall,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.align(alignment),
+ )
+ }
+ }
+
+ val largeRadialGradient =
+ remember {
+ object : ShaderBrush() {
+ override fun createShader(size: Size): Shader {
+ val biggerDimension = maxOf(size.height, size.width)
+ return RadialGradientShader(
+ colors = listOf(Color.Transparent, AppColors.TransparentBlack25),
+ center = size.center,
+ radius = biggerDimension / 1.5f,
+ colorStops = listOf(0f, 0.85f),
+ )
+ }
+ }
+ }
+ Canvas(Modifier.fillMaxSize()) {
+ drawRect(
+ brush = largeRadialGradient,
+ blendMode = BlendMode.Multiply,
+ )
+ if (showClock) {
+ // Add scrim to make clock more readable
+ drawRect(
+ brush =
+ Brush.verticalGradient(
+ colorStops =
+ arrayOf(
+ 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
+ TOP_SCRIM_END_FRACTION to Color.Transparent,
+ ),
+ ),
+ blendMode = BlendMode.Multiply,
+ )
+ }
+ }
+ if (showClock) {
+ TimeDisplay()
+ }
+ }
+}
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 29991126..8415c7a6 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
@@ -2,11 +2,11 @@ package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -33,6 +33,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -41,6 +42,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@@ -55,10 +57,16 @@ 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.data.model.LibraryDisplayInfo
+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.MediaManagementService
+import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
+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.AspectRatios
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.SlimItemFields
@@ -71,15 +79,19 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
+import com.github.damontecres.wholphin.ui.equalsNotNull
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageHeader
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.setValueOnMain
+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.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
@@ -92,6 +104,9 @@ import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@@ -119,7 +134,11 @@ class CollectionFolderViewModel
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
- val navigationManager: NavigationManager,
+ private val navigationManager: NavigationManager,
+ private val themeSongPlayer: ThemeSongPlayer,
+ private val userPreferencesService: UserPreferencesService,
+ private val mediaManagementService: MediaManagementService,
+ val mediaReportService: MediaReportService,
@Assisted itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?,
@Assisted("recursive") private val recursive: Boolean,
@@ -155,9 +174,10 @@ class CollectionFolderViewModel
viewModelScope.launchIO {
super.itemId = itemId
try {
- itemId.toUUIDOrNull()?.let {
- fetchItem(it)
- }
+ val item =
+ itemId.toUUIDOrNull()?.let {
+ fetchItem(it)
+ }
val libraryDisplayInfo =
serverRepository.currentUser.value?.let { user ->
@@ -182,11 +202,42 @@ class CollectionFolderViewModel
}
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
+ .join()
+// onResumePage()
} catch (ex: Exception) {
Timber.e(ex, "Error during init")
loading.setValueOnMain(DataLoadingState.Error(ex))
}
}
+ mediaManagementService.deletedItemFlow
+ .onEach { deletedItem ->
+ refreshAfterDelete(position, deletedItem.item)
+ }.catch { ex ->
+ Timber.e(ex, "Error refreshing after deleted item")
+ }.launchIn(viewModelScope)
+ }
+
+ private suspend fun refreshAfterDelete(
+ position: Int,
+ deletedItem: BaseItem,
+ ) {
+ try {
+ val pager =
+ ((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
+ position.let {
+ Timber.v("Item deleted: position=%s, id=%s", it, itemId)
+ 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", itemId)
+ showToast(context, "Error refreshing after item deleted")
+ }
}
private fun saveLibraryDisplayInfo(
@@ -252,34 +303,32 @@ class CollectionFolderViewModel
recursive: Boolean,
filter: GetItemsFilter,
useSeriesForPrimary: Boolean,
- ) {
- viewModelScope.launch(Dispatchers.IO) {
- withContext(Dispatchers.Main) {
- if (resetState) {
- loading.value = DataLoadingState.Loading
- }
- backgroundLoading.value = LoadingState.Loading
- this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
- this@CollectionFolderViewModel.filter.value = filter
+ ) = viewModelScope.launch(Dispatchers.IO) {
+ withContext(Dispatchers.Main) {
+ if (resetState) {
+ loading.value = DataLoadingState.Loading
}
- try {
- val newPager =
- createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
- if (newPager.isNotEmpty()) newPager.getBlocking(0)
- withContext(Dispatchers.Main) {
- loading.value = DataLoadingState.Success(newPager)
- backgroundLoading.value = LoadingState.Success
- }
- } catch (ex: Exception) {
- Timber.e(
- ex,
- "Exception while loading data: sort=%s, filter=%s",
- sortAndDirection,
- filter,
- )
- withContext(Dispatchers.Main) {
- loading.value = DataLoadingState.Error(ex)
- }
+ backgroundLoading.value = LoadingState.Loading
+ this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
+ this@CollectionFolderViewModel.filter.value = filter
+ }
+ try {
+ val newPager =
+ createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
+ if (newPager.isNotEmpty()) newPager.getBlocking(0)
+ withContext(Dispatchers.Main) {
+ loading.value = DataLoadingState.Success(newPager)
+ backgroundLoading.value = LoadingState.Success
+ }
+ } catch (ex: Exception) {
+ Timber.e(
+ ex,
+ "Exception while loading data: sort=%s, filter=%s",
+ sortAndDirection,
+ filter,
+ )
+ withContext(Dispatchers.Main) {
+ loading.value = DataLoadingState.Error(ex)
}
}
}
@@ -344,7 +393,12 @@ class CollectionFolderViewModel
filter.applyTo(
GetItemsRequest(
parentId = item?.id,
- enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
+ enableImageTypes =
+ listOf(
+ ImageType.PRIMARY,
+ ImageType.THUMB,
+ ImageType.BACKDROP,
+ ),
includeItemTypes = includeItemTypes,
recursive = recursive,
excludeItemIds = item?.let { listOf(item.id) },
@@ -436,6 +490,46 @@ class CollectionFolderViewModel
backdropService.submit(item)
}
}
+
+ fun navigateTo(destination: Destination) {
+ release()
+ navigationManager.navigateTo(destination)
+ }
+
+ fun release() {
+ themeSongPlayer.stop()
+ }
+
+ fun onResumePage() {
+ viewModelScope.launchIO {
+ item.value?.let {
+ Timber.v("onResumePage: %s", loading.value!!::class)
+ if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) {
+ val volume =
+ userPreferencesService
+ .getCurrent()
+ .appPreferences.interfacePreferences.playThemeSongs
+ themeSongPlayer.playThemeFor(it.id, volume)
+ }
+ }
+ }
+ }
+
+ fun deleteItem(
+ index: Int,
+ item: BaseItem,
+ ) {
+ deleteItem(context, mediaManagementService, item) {
+ viewModelScope.launchDefault {
+ refreshAfterDelete(index, item)
+ }
+ }
+ }
+
+ fun canDelete(
+ item: BaseItem,
+ appPreferences: AppPreferences,
+ ): Boolean = mediaManagementService.canDelete(item, appPreferences)
}
/**
@@ -523,13 +617,14 @@ fun CollectionFolderGrid(
var moreDialog by remember { mutableStateOf>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
+ var showDeleteDialog by remember { mutableStateOf(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
when (val state = loading) {
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
- LoadingPage()
+ LoadingPage(modifier)
}
is DataLoadingState.Error,
@@ -541,6 +636,13 @@ fun CollectionFolderGrid(
?: item?.data?.collectionType?.name
?: stringResource(R.string.collection)
Box(modifier = modifier) {
+ LifecycleResumeEffect(itemId) {
+ viewModel.onResumePage()
+
+ onPauseOrDispose {
+ viewModel.release()
+ }
+ }
CollectionFolderGridContent(
preferences = preferences,
initialPosition = viewModel.position,
@@ -577,21 +679,45 @@ fun CollectionFolderGrid(
onSaveViewOptions = { viewModel.saveViewOptions(it) },
onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled,
- onClickPlay = { _, item ->
- viewModel.navigationManager.navigateTo(Destination.Playback(item))
+ onClickPlay = { index, item ->
+ val destination =
+ if (item.type == BaseItemKind.PHOTO_ALBUM) {
+ Destination.Slideshow(
+ parentId = item.id,
+ index = index,
+ filter = CollectionFolderFilter(filter = filter),
+ sortAndDirection = sortAndDirection,
+ recursive = true,
+ startSlideshow = true,
+ )
+ } else {
+ Destination.Playback(item)
+ }
+ viewModel.navigateTo(destination)
},
onClickPlayAll = { shuffle ->
itemId.toUUIDOrNull()?.let {
- viewModel.navigationManager.navigateTo(
- Destination.PlaybackList(
- itemId = it,
- startIndex = 0,
- shuffle = shuffle,
- recursive = recursive,
- sortAndDirection = sortAndDirection,
- filter = filter,
- ),
- )
+ val destination =
+ if (item?.type == BaseItemKind.PHOTO_ALBUM) {
+ Destination.Slideshow(
+ parentId = it,
+ index = 0,
+ filter = CollectionFolderFilter(filter = filter),
+ sortAndDirection = sortAndDirection,
+ recursive = true,
+ startSlideshow = true,
+ )
+ } else {
+ Destination.PlaybackList(
+ itemId = it,
+ startIndex = 0,
+ shuffle = shuffle,
+ recursive = recursive,
+ sortAndDirection = sortAndDirection,
+ filter = filter,
+ )
+ }
+ viewModel.navigateTo(destination)
}
},
)
@@ -627,9 +753,10 @@ fun CollectionFolderGrid(
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
+ canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions =
MoreDialogActions(
- navigateTo = { viewModel.navigationManager.navigateTo(it) },
+ navigateTo = { viewModel.navigateTo(it) },
onClickWatch = { itemId, watched ->
viewModel.setWatched(position, itemId, watched)
},
@@ -640,6 +767,10 @@ fun CollectionFolderGrid(
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(it)
},
+ onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
+ onClickDelete = {
+ showDeleteDialog = PositionItem(position, item)
+ },
),
),
onDismissRequest = { moreDialog.makeAbsent() },
@@ -664,8 +795,19 @@ fun CollectionFolderGrid(
elevation = 3.dp,
)
}
+ showDeleteDialog?.let { (position, item) ->
+ ConfirmDeleteDialog(
+ itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
+ onCancel = { showDeleteDialog = null },
+ onConfirm = {
+ viewModel.deleteItem(position, item)
+ showDeleteDialog = null
+ },
+ )
+ }
}
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CollectionFolderGridContent(
preferences: UserPreferences,
@@ -728,8 +870,8 @@ fun CollectionFolderGridContent(
) {
AnimatedVisibility(
showHeader || loadingState !is DataLoadingState.Success,
- enter = slideInVertically() + fadeIn(),
- exit = slideOutVertically() + fadeOut(),
+ enter = expandVertically(),
+ exit = shrinkVertically(),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -810,14 +952,16 @@ fun CollectionFolderGridContent(
}
}
}
+ val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
+ val density = LocalDensity.current
AnimatedVisibility(viewOptions.showDetails) {
HomePageHeader(
item = focusedItem,
modifier =
Modifier
.fillMaxWidth()
- .height(140.dp)
- .padding(16.dp),
+ .height(200.dp)
+ .padding(top = 48.dp, bottom = 32.dp, start = 8.dp),
)
}
when (val state = loadingState) {
@@ -825,7 +969,7 @@ fun CollectionFolderGridContent(
DataLoadingState.Loading,
-> {
// This shouldn't happen, so just show placeholder
- Text("Loading")
+ Text(stringResource(R.string.loading))
}
is DataLoadingState.Error -> {
@@ -863,6 +1007,15 @@ fun CollectionFolderGridContent(
},
columns = viewOptions.columns,
spacing = viewOptions.spacing.dp,
+ bringIntoViewSpec =
+ remember(viewOptions) {
+ val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
+ if (viewOptions.showDetails) {
+ ScrollToTopBringIntoViewSpec(spacingPx)
+ } else {
+ defaultBringIntoViewSpec
+ }
+ },
)
AnimatedVisibility(showViewOptions) {
ViewOptionsDialog(
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 d5897a6b..a96bc859 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
@@ -6,6 +6,8 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
+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.BoxScope
@@ -17,11 +19,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -30,9 +35,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
@@ -56,8 +64,12 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.FontAwesome
+import com.github.damontecres.wholphin.ui.ifElse
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.util.ExceptionHandler
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -259,31 +271,65 @@ fun DialogPopupContent(
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
+ val scope = rememberCoroutineScope()
+ val listState = rememberLazyListState()
+ val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } }
LazyColumn(
+ state = listState,
modifier = Modifier,
) {
- items(dialogItems) {
- when (it) {
+ itemsIndexed(dialogItems) { index, item ->
+ when (item) {
is DialogItemDivider -> {
HorizontalDivider(Modifier.height(16.dp))
}
is DialogItem -> {
+ val interactionSource = remember { MutableInteractionSource() }
+ val focused by interactionSource.collectIsFocusedAsState()
ListItem(
- selected = it.selected,
- enabled = !waiting && it.enabled,
+ selected = item.selected,
+ enabled = !waiting && item.enabled,
onClick = {
if (dismissOnClick) {
onDismissRequest.invoke()
}
- it.onClick.invoke()
+ item.onClick.invoke()
},
- headlineContent = it.headlineContent,
- overlineContent = it.overlineContent,
- supportingContent = it.supportingContent,
- leadingContent = it.leadingContent,
- trailingContent = it.trailingContent,
- modifier = Modifier,
+ headlineContent = item.headlineContent,
+ overlineContent = item.overlineContent,
+ supportingContent = item.supportingContent,
+ leadingContent = item.leadingContent,
+ trailingContent = item.trailingContent,
+ interactionSource = interactionSource,
+ modifier =
+ Modifier
+ .focusRequester(focusRequesters[index])
+ .ifElse(
+ 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
+ },
+ ),
)
}
}
@@ -408,12 +454,13 @@ fun ConfirmDialog(
onConfirm: () -> Unit,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 8.dp,
+ bodyColor: Color = MaterialTheme.colorScheme.onSurface,
) = BasicDialog(
onDismissRequest = onCancel,
properties = properties,
elevation = elevation,
content = {
- ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier)
+ ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor)
},
)
@@ -427,6 +474,7 @@ fun ConfirmDialogContent(
onCancel: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
+ bodyColor: Color = MaterialTheme.colorScheme.onSurface,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -446,7 +494,7 @@ fun ConfirmDialogContent(
item {
Text(
text = body,
- color = MaterialTheme.colorScheme.onSurface,
+ color = bodyColor,
)
}
}
@@ -469,6 +517,80 @@ fun ConfirmDialogContent(
}
}
+@Composable
+fun ConfirmDeleteDialog(
+ itemTitle: String,
+ onCancel: () -> Unit,
+ onConfirm: () -> Unit,
+) {
+ BasicDialog(
+ onDismissRequest = onCancel,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ LazyColumn(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(16.dp),
+ modifier = Modifier.wrapContentWidth(),
+ ) {
+ item {
+ Text(
+ text = stringResource(R.string.delete_item),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ item {
+ Text(
+ text = itemTitle,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier =
+ Modifier
+ .padding(bottom = 8.dp),
+ )
+ }
+
+ item {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(32.dp),
+ modifier = Modifier,
+ ) {
+ Button(
+ onClick = onCancel,
+ modifier = Modifier.width(72.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.cancel),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ Button(
+ onClick = onConfirm,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(horizontal = 4.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = null,
+ tint = Color.Red.copy(alpha = .8f),
+ modifier = Modifier,
+ )
+ Text(
+ text = stringResource(R.string.confirm),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
fun chooseVersionParams(
context: Context,
sources: List,
@@ -534,6 +656,9 @@ fun chooseStream(
)
add(
DialogItem(
+ leadingContent = {
+ SelectedLeadingContent(currentIndex == TrackIndex.ONLY_FORCED)
+ },
headlineContent = {
Text(text = stringResource(R.string.only_forced_subtitles))
},
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt
new file mode 100644
index 00000000..b04bf451
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt
@@ -0,0 +1,78 @@
+package com.github.damontecres.wholphin.ui.components
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+
+/**
+ * Placeholder for [com.github.damontecres.wholphin.ui.cards.ItemRow]. It is [focusable] so it can be scrolled.
+ */
+@Composable
+@NonRestartableComposable
+fun FocusableItemRow(
+ title: String,
+ subtitle: String,
+ modifier: Modifier = Modifier,
+ isError: Boolean = false,
+) = FocusableItemRow(
+ titleContent = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ },
+ subtitleContent = {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.titleMedium,
+ color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ },
+ modifier = modifier,
+)
+
+@Composable
+fun FocusableItemRow(
+ titleContent: @Composable () -> Unit,
+ subtitleContent: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val focused by interactionSource.collectIsFocusedAsState()
+ val background by animateColorAsState(
+ if (focused) {
+ MaterialTheme.colorScheme.border.copy(alpha = .25f)
+ } else {
+ Color.Unspecified
+ },
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .padding(start = 8.dp)
+ .focusable(interactionSource = interactionSource)
+ .background(background, shape = RoundedCornerShape(8.dp))
+ .padding(8.dp),
+ ) {
+ titleContent.invoke()
+ subtitleContent.invoke()
+ }
+}
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 cdf18969..d42eea5c 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
@@ -5,12 +5,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
@@ -21,8 +21,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.CollectionFolderFilter
-import com.github.damontecres.wholphin.data.model.GetItemsFilter
+import com.github.damontecres.wholphin.data.model.createGenreDestination
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@@ -30,17 +29,18 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.CardGridItem
-import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
+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
@@ -56,8 +56,10 @@ import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import timber.log.Timber
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
+import kotlin.time.Duration.Companion.hours
@HiltViewModel(assistedFactory = GenreViewModel.Factory::class)
class GenreViewModel
@@ -102,51 +104,23 @@ class GenreViewModel
.execute(api, request)
.content.items
.map {
- Genre(it.id, it.name ?: "", null, Color.Black)
+ Genre(it.id, it.name ?: "", null)
}
withContext(Dispatchers.Main) {
this@GenreViewModel.genres.value = genres
loading.value = LoadingState.Success
}
- val genreToUrl = ConcurrentHashMap()
- val semaphore = Semaphore(4)
- genres
- .map { genre ->
- viewModelScope.async(Dispatchers.IO) {
- semaphore.withPermit {
- val item =
- GetItemsRequestHandler
- .execute(
- api,
- GetItemsRequest(
- parentId = itemId,
- recursive = true,
- limit = 1,
- sortBy = listOf(ItemSortBy.RANDOM),
- fields = listOf(ItemFields.GENRES),
- imageTypes = listOf(ImageType.BACKDROP),
- imageTypeLimit = 1,
- includeItemTypes = includeItemTypes,
- genreIds = listOf(genre.id),
- enableTotalRecordCount = false,
- ),
- ).content.items
- .firstOrNull()
- if (item != null) {
- genreToUrl[genre.id] =
- imageUrlService.getItemImageUrl(
- itemId = item.id,
- itemType = item.type,
- seriesId = null,
- useSeriesForPrimary = true,
- imageType = ImageType.BACKDROP,
- imageTags = item.imageTags.orEmpty(),
- fillWidth = cardWidthPx,
- )
- }
- }
- }
- }.awaitAll()
+ val genreToUrl =
+ getGenreImageMap(
+ api = api,
+ userId = serverRepository.currentUser.value?.id,
+ scope = viewModelScope,
+ imageUrlService = imageUrlService,
+ genres = genres.map { it.id },
+ parentId = itemId,
+ includeItemTypes = includeItemTypes,
+ cardWidthPx = cardWidthPx,
+ )
val genresWithImages =
genres.map {
it.copy(
@@ -171,11 +145,85 @@ class GenreViewModel
}
}
+data class GenreCacheKey(
+ val userId: UUID?,
+ val parentId: UUID,
+)
+
+private val genreCache by lazy {
+ InMemoryKache>(8) {
+ expireAfterWriteDuration = 2.hours
+ }
+}
+
+suspend fun getGenreImageMap(
+ api: ApiClient,
+ userId: UUID?,
+ scope: CoroutineScope,
+ imageUrlService: ImageUrlService,
+ genres: List,
+ parentId: UUID,
+ includeItemTypes: List?,
+ cardWidthPx: Int?,
+ useCache: Boolean = true,
+): Map {
+ val key = GenreCacheKey(userId, parentId)
+ if (useCache) {
+ genreCache.getIfAvailable(key)?.let {
+ Timber.v("Got cached entry")
+ return it
+ }
+ }
+ val genreToUrl = ConcurrentHashMap()
+ val semaphore = Semaphore(4)
+ genres
+ .map { genreId ->
+ scope.async(Dispatchers.IO) {
+ semaphore.withPermit {
+ val item =
+ GetItemsRequestHandler
+ .execute(
+ api,
+ GetItemsRequest(
+ userId = userId,
+ parentId = parentId,
+ recursive = true,
+ limit = 1,
+ sortBy = listOf(ItemSortBy.RANDOM),
+ fields = listOf(ItemFields.GENRES),
+ imageTypes = listOf(ImageType.BACKDROP),
+ imageTypeLimit = 1,
+ includeItemTypes = includeItemTypes,
+ genreIds = listOf(genreId),
+ enableTotalRecordCount = false,
+ ),
+ ).content.items
+ .firstOrNull()
+ if (item != null) {
+ genreToUrl[genreId] =
+ imageUrlService.getItemImageUrl(
+ itemId = item.id,
+ itemType = item.type,
+ seriesId = null,
+ useSeriesForPrimary = true,
+ imageType = ImageType.BACKDROP,
+ imageTags = item.imageTags.orEmpty(),
+ fillWidth = cardWidthPx,
+ backdropTags = item.backdropImageTags.orEmpty(),
+ )
+ }
+ }
+ }
+ }.awaitAll()
+ genreCache.put(key, genreToUrl)
+ return genreToUrl
+}
+
+@Stable
data class Genre(
val id: UUID,
val name: String,
val imageUrl: String?,
- val color: Color,
) : CardGridItem {
override val gridId: String get() = id.toString()
override val playable: Boolean = false
@@ -233,23 +281,12 @@ fun GenreCardGrid(
pager = genres,
onClickItem = { _, genre ->
viewModel.navigationManager.navigateTo(
- Destination.FilteredCollection(
- itemId = itemId,
- filter =
- CollectionFolderFilter(
- nameOverride =
- listOfNotNull(
- genre.name,
- item?.title,
- ).joinToString(" "),
- filter =
- GetItemsFilter(
- genres = listOf(genre.id),
- includeItemTypes = includeItemTypes,
- ),
- useSavedLibraryDisplayInfo = false,
- ),
- recursive = true,
+ createGenreDestination(
+ genreId = genre.id,
+ genreName = genre.name,
+ parentId = itemId,
+ parentName = item?.title,
+ includeItemTypes = includeItemTypes,
),
)
},
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 1e274de6..38591a67 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
@@ -95,13 +95,13 @@ fun ItemGrid(
val items by viewModel.items.observeAsState(listOf())
when (val state = loading) {
is LoadingState.Error -> {
- ErrorMessage(state)
+ ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
- LoadingPage()
+ LoadingPage(modifier)
}
LoadingState.Success -> {
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 2856f319..75a3147c 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
@@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.focusGroup
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.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.requiredSizeIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
@@ -34,12 +39,16 @@ import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.isSpecified
+import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
+import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
@@ -63,12 +72,14 @@ fun ExpandablePlayButtons(
resumePosition: Duration,
watched: Boolean,
favorite: Boolean,
+ canDelete: Boolean,
trailers: List?,
playOnClick: (position: Duration) -> Unit,
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit,
+ deleteOnClick: () -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -149,6 +160,16 @@ fun ExpandablePlayButtons(
)
}
}
+ if (canDelete) {
+ item("delete") {
+ DeleteButton(
+ onClick = deleteOnClick,
+ modifier =
+ Modifier
+ .onFocusChanged(buttonOnFocusChanged),
+ )
+ }
+ }
// More button
item("more") {
@@ -180,6 +201,63 @@ fun ExpandablePlayButton(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
mirrorIcon: Boolean = false,
enabled: Boolean = true,
+) = ExpandablePlayButton(
+ title = title,
+ resume = resume,
+ icon = {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier =
+ Modifier
+ .size(28.dp)
+ .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
+ )
+ },
+ onClick = onClick,
+ modifier = modifier,
+ interactionSource = interactionSource,
+ enabled = enabled,
+)
+
+@Composable
+fun ExpandablePlayButton(
+ @StringRes title: Int,
+ resume: Duration,
+ icon: Painter,
+ onClick: (position: Duration) -> Unit,
+ modifier: Modifier = Modifier,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ mirrorIcon: Boolean = false,
+ enabled: Boolean = true,
+) = ExpandablePlayButton(
+ title = title,
+ resume = resume,
+ icon = {
+ Icon(
+ painter = icon,
+ contentDescription = null,
+ modifier =
+ Modifier
+ .size(28.dp)
+ .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
+ )
+ },
+ onClick = onClick,
+ modifier = modifier,
+ interactionSource = interactionSource,
+ enabled = enabled,
+)
+
+@Composable
+fun ExpandablePlayButton(
+ @StringRes title: Int,
+ resume: Duration,
+ icon: @Composable BoxScope.() -> Unit,
+ onClick: (position: Duration) -> Unit,
+ modifier: Modifier = Modifier,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ enabled: Boolean = true,
) {
val isFocused = interactionSource.collectIsFocusedAsState().value
Button(
@@ -195,19 +273,13 @@ fun ExpandablePlayButton(
interactionSource = interactionSource,
) {
Box(
+ contentAlignment = Alignment.Center,
modifier =
Modifier
- .padding(start = 2.dp, top = 2.dp)
+ .padding(start = 2.dp)
.height(MinButtonSize),
) {
- Icon(
- imageVector = icon,
- contentDescription = null,
- modifier =
- Modifier
- .size(28.dp)
- .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
- )
+ icon.invoke(this)
}
AnimatedVisibility(isFocused) {
Spacer(Modifier.size(8.dp))
@@ -307,6 +379,48 @@ fun TrailerButton(
}
}
+@Composable
+fun DeleteButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ val focused by interactionSource.collectIsFocusedAsState()
+ val iconTint by
+ animateColorAsState(
+ targetValue =
+ if (focused) {
+ Color.Red.copy(alpha = .8f)
+ } else {
+ MaterialTheme.colorScheme.onSurface.copy(
+ alpha = 0.8f,
+ )
+ },
+ animationSpec =
+ tween(
+ easing = LinearEasing,
+ ),
+ )
+ ExpandablePlayButton(
+ title = R.string.delete,
+ resume = Duration.ZERO,
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = null,
+ tint = if (iconTint.isSpecified) iconTint else LocalContentColor.current,
+ modifier =
+ Modifier
+ .padding(start = 2.dp)
+ .size(24.dp),
+ )
+ },
+ onClick = { onClick.invoke() },
+ interactionSource = interactionSource,
+ modifier = modifier,
+ )
+}
+
@PreviewTvSpec
@Composable
private fun ExpandablePlayButtonsPreview() {
@@ -322,6 +436,8 @@ private fun ExpandablePlayButtonsPreview() {
buttonOnFocusChanged = {},
trailers = listOf(),
trailerOnClick = {},
+ canDelete = true,
+ deleteOnClick = {},
modifier = Modifier,
)
}
@@ -343,6 +459,13 @@ private fun ViewOptionsPreview() {
onClick = {},
interactionSource = source,
)
+ ExpandablePlayButton(
+ title = R.string.play,
+ resume = Duration.ZERO,
+ icon = painterResource(R.drawable.baseline_pause_24),
+ onClick = {},
+ interactionSource = source,
+ )
ExpandableFaButton(
title = R.string.play,
iconStringRes = R.string.fa_eye,
@@ -358,12 +481,19 @@ private fun ViewOptionsPreview() {
onClick = {},
modifier = Modifier,
)
+ DeleteButton(
+ onClick = {},
+ )
SortByButton(
sortOptions = listOf(),
current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
onSortChange = {},
)
}
+ DeleteButton(
+ onClick = {},
+ interactionSource = source,
+ )
}
}
}
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 869a454d..c3d1a4b6 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
@@ -19,10 +19,14 @@ 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.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.MediaManagementService
+import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
@@ -30,15 +34,18 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
+import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID
@@ -46,7 +53,9 @@ abstract class RecommendedViewModel(
val context: Context,
val navigationManager: NavigationManager,
val favoriteWatchManager: FavoriteWatchManager,
+ val mediaReportService: MediaReportService,
private val backdropService: BackdropService,
+ private val mediaManagementService: MediaManagementService,
) : ViewModel() {
abstract fun init()
@@ -97,13 +106,13 @@ abstract class RecommendedViewModel(
abstract fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
- )
+ ): HomeRowLoadingState
fun update(
@StringRes title: Int,
block: suspend () -> List,
- ) {
- viewModelScope.launch(Dispatchers.IO) {
+ ): Deferred =
+ viewModelScope.async(Dispatchers.IO) {
val titleStr = context.getString(title)
val row =
try {
@@ -113,7 +122,25 @@ abstract class RecommendedViewModel(
}
update(title, row)
}
+
+ fun deleteItem(
+ position: RowColumn,
+ item: BaseItem,
+ ) {
+ deleteItem(context, mediaManagementService, item) {
+ viewModelScope.launchDefault {
+ val row = rows.value.getOrNull(position.row)
+ if (row is HomeRowLoadingState.Success) {
+ (row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column)
+ }
+ }
+ }
}
+
+ fun canDelete(
+ item: BaseItem,
+ appPreferences: AppPreferences,
+ ): Boolean = mediaManagementService.canDelete(item, appPreferences)
}
@Composable
@@ -127,6 +154,7 @@ fun RecommendedContent(
val context = LocalContext.current
var moreDialog by remember { mutableStateOf>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
+ var showDeleteDialog by remember { mutableStateOf(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
OneTimeLaunchedEffect {
@@ -137,18 +165,20 @@ fun RecommendedContent(
when (val state = loading) {
is LoadingState.Error -> {
- ErrorMessage(state)
+ ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
- LoadingPage()
+ LoadingPage(modifier)
}
LoadingState.Success -> {
+ var position by rememberPosition()
HomePageContent(
homeRows = rows,
+ position = position,
onClickItem = { _, item ->
viewModel.navigationManager.navigateTo(item.destination())
},
@@ -158,7 +188,21 @@ fun RecommendedContent(
onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item))
},
- onFocusPosition = onFocusPosition,
+ onFocusPosition = {
+ position = it
+ val nonEmptyRowBefore =
+ rows
+ .subList(0, it.row)
+ .count {
+ it is HomeRowLoadingState.Success && it.items.isEmpty()
+ }
+ onFocusPosition?.invoke(
+ RowColumn(
+ it.row - nonEmptyRowBefore,
+ it.column,
+ ),
+ )
+ },
showClock = preferences.appPreferences.interfacePreferences.showClock,
onUpdateBackdrop = viewModel::updateBackdrop,
modifier = modifier,
@@ -177,6 +221,7 @@ fun RecommendedContent(
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
+ canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions =
MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
@@ -190,6 +235,8 @@ fun RecommendedContent(
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(it)
},
+ onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
+ onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
),
),
onDismissRequest = { moreDialog.makeAbsent() },
@@ -214,9 +261,19 @@ fun RecommendedContent(
elevation = 3.dp,
)
}
+ showDeleteDialog?.let { (position, item) ->
+ ConfirmDeleteDialog(
+ itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
+ onCancel = { showDeleteDialog = null },
+ onConfirm = {
+ viewModel.deleteItem(position, item)
+ showDeleteDialog = null
+ },
+ )
+ }
}
-private data class RowColumnItem(
+data class RowColumnItem(
val position: RowColumn,
val item: BaseItem,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt
index 1272faf5..392c77a5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt
@@ -14,7 +14,11 @@ 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.MediaManagementService
+import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.SuggestionService
+import com.github.damontecres.wholphin.services.SuggestionsResource
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.setValueOnMain
@@ -22,7 +26,6 @@ import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
-import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
@@ -30,6 +33,8 @@ 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.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
@@ -42,7 +47,6 @@ import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
-import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber
import java.util.UUID
@@ -54,11 +58,21 @@ class RecommendedMovieViewModel
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore,
+ private val suggestionService: SuggestionService,
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
+ mediaReportService: MediaReportService,
backdropService: BackdropService,
- ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
+ mediaManagementService: MediaManagementService,
+ ) : RecommendedViewModel(
+ context,
+ navigationManager,
+ favoriteWatchManager,
+ mediaReportService,
+ backdropService,
+ mediaManagementService,
+ ) {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedMovieViewModel
@@ -114,8 +128,10 @@ class RecommendedMovieViewModel
}
}
+ val jobs = mutableListOf>()
+
update(R.string.recently_released) {
- val recentlyReleasedRequest =
+ val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@@ -128,13 +144,11 @@ class RecommendedMovieViewModel
limit = itemsPerRow,
enableTotalRecordCount = false,
)
- GetItemsRequestHandler
- .execute(api, recentlyReleasedRequest)
- .toBaseItems(api, false)
- }
+ GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
+ }.also(jobs::add)
update(R.string.recently_added) {
- val recentlyAddedRequest =
+ val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@@ -147,27 +161,11 @@ class RecommendedMovieViewModel
limit = itemsPerRow,
enableTotalRecordCount = false,
)
- GetItemsRequestHandler
- .execute(api, recentlyAddedRequest)
- .toBaseItems(api, false)
- }
-
- update(R.string.suggestions) {
- val suggestionsRequest =
- GetSuggestionsRequest(
- userId = serverRepository.currentUser.value?.id,
- type = listOf(BaseItemKind.MOVIE),
- startIndex = 0,
- limit = itemsPerRow,
- enableTotalRecordCount = false,
- )
- GetSuggestionsRequestHandler
- .execute(api, suggestionsRequest)
- .toBaseItems(api, false)
- }
+ GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
+ }.also(jobs::add)
update(R.string.top_unwatched) {
- val unwatchedTopRatedRequest =
+ val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@@ -181,13 +179,63 @@ class RecommendedMovieViewModel
limit = itemsPerRow,
enableTotalRecordCount = false,
)
- GetItemsRequestHandler
- .execute(api, unwatchedTopRatedRequest)
- .toBaseItems(api, false)
+ GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
+ }.also(jobs::add)
+
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ suggestionService
+ .getSuggestionsFlow(parentId, BaseItemKind.MOVIE)
+ .collect { resource ->
+ val state =
+ when (resource) {
+ is SuggestionsResource.Loading -> {
+ HomeRowLoadingState.Loading(
+ context.getString(R.string.suggestions),
+ )
+ }
+
+ is SuggestionsResource.Success -> {
+ HomeRowLoadingState.Success(
+ context.getString(R.string.suggestions),
+ resource.items,
+ )
+ }
+
+ is SuggestionsResource.Empty -> {
+ HomeRowLoadingState.Success(
+ context.getString(R.string.suggestions),
+ emptyList(),
+ )
+ }
+ }
+ update(R.string.suggestions, state)
+ }
+ } catch (ex: CancellationException) {
+ throw ex
+ } catch (ex: Exception) {
+ Timber.e(ex, "Failed to fetch suggestions")
+ update(
+ R.string.suggestions,
+ HomeRowLoadingState.Error(
+ title = context.getString(R.string.suggestions),
+ exception = ex,
+ ),
+ )
+ }
}
+ // If the continue watching row is empty, then wait until the first successful row
+ // is loaded before telling the UI that the page is loaded
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
- loading.setValueOnMain(LoadingState.Success)
+ for (i in 0..
current.toMutableList().apply { set(rowTitles[title]!!, row) }
}
+ return row
}
companion object {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt
index fa4071b6..db7f7b4f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt
@@ -14,7 +14,11 @@ 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.LatestNextUpService
+import com.github.damontecres.wholphin.services.MediaManagementService
+import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.SuggestionService
+import com.github.damontecres.wholphin.services.SuggestionsResource
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.setValueOnMain
@@ -23,7 +27,6 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
-import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
@@ -31,6 +34,8 @@ 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.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
@@ -45,7 +50,6 @@ import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
-import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber
import java.util.UUID
@@ -58,11 +62,21 @@ class RecommendedTvShowViewModel
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore,
private val lastestNextUpService: LatestNextUpService,
+ private val suggestionService: SuggestionService,
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
+ mediaReportService: MediaReportService,
backdropService: BackdropService,
- ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
+ mediaManagementService: MediaManagementService,
+ ) : RecommendedViewModel(
+ context,
+ navigationManager,
+ favoriteWatchManager,
+ mediaReportService,
+ backdropService,
+ mediaManagementService,
+ ) {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedTvShowViewModel
@@ -86,7 +100,7 @@ class RecommendedTvShowViewModel
val userId = serverRepository.currentUser.value?.id
try {
val resumeItemsDeferred =
- viewModelScope.async(Dispatchers.IO) {
+ async(Dispatchers.IO) {
val resumeItemsRequest =
GetResumeItemsRequest(
userId = userId,
@@ -104,7 +118,7 @@ class RecommendedTvShowViewModel
}
val nextUpItemsDeferred =
- viewModelScope.async(Dispatchers.IO) {
+ async(Dispatchers.IO) {
val nextUpRequest =
GetNextUpRequest(
userId = userId,
@@ -116,19 +130,16 @@ class RecommendedTvShowViewModel
enableUserData = true,
enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp,
)
-
GetNextUpRequestHandler
.execute(api, nextUpRequest)
.toBaseItems(api, true)
}
+
val resumeItems = resumeItemsDeferred.await()
val nextUpItems = nextUpItemsDeferred.await()
+
if (combineNextUp) {
- val combined =
- lastestNextUpService.buildCombined(
- resumeItems,
- nextUpItems,
- )
+ val combined = lastestNextUpService.buildCombined(resumeItems, nextUpItems)
update(
R.string.continue_watching,
HomeRowLoadingState.Success(
@@ -138,10 +149,7 @@ class RecommendedTvShowViewModel
)
update(
R.string.next_up,
- HomeRowLoadingState.Success(
- context.getString(R.string.next_up),
- listOf(),
- ),
+ HomeRowLoadingState.Success(context.getString(R.string.next_up), listOf()),
)
} else {
update(
@@ -153,10 +161,7 @@ class RecommendedTvShowViewModel
)
update(
R.string.next_up,
- HomeRowLoadingState.Success(
- context.getString(R.string.next_up),
- nextUpItems,
- ),
+ HomeRowLoadingState.Success(context.getString(R.string.next_up), nextUpItems),
)
}
@@ -170,8 +175,10 @@ class RecommendedTvShowViewModel
}
}
+ val jobs = mutableListOf>()
+
update(R.string.recently_released) {
- val recentlyReleasedRequest =
+ val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@@ -184,14 +191,11 @@ class RecommendedTvShowViewModel
limit = itemsPerRow,
enableTotalRecordCount = false,
)
-
- GetItemsRequestHandler
- .execute(api, recentlyReleasedRequest)
- .toBaseItems(api, true)
- }
+ GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
+ }.also(jobs::add)
update(R.string.recently_added) {
- val recentlyAddedRequest =
+ val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@@ -204,29 +208,11 @@ class RecommendedTvShowViewModel
limit = itemsPerRow,
enableTotalRecordCount = false,
)
-
- GetItemsRequestHandler
- .execute(api, recentlyAddedRequest)
- .toBaseItems(api, true)
- }
-
- update(R.string.suggestions) {
- val suggestionsRequest =
- GetSuggestionsRequest(
- userId = serverRepository.currentUser.value?.id,
- type = listOf(BaseItemKind.SERIES),
- startIndex = 0,
- limit = itemsPerRow,
- enableTotalRecordCount = false,
- )
-
- GetSuggestionsRequestHandler
- .execute(api, suggestionsRequest)
- .toBaseItems(api, true)
- }
+ GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
+ }.also(jobs::add)
update(R.string.top_unwatched) {
- val unwatchedTopRatedRequest =
+ val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@@ -240,13 +226,61 @@ class RecommendedTvShowViewModel
limit = itemsPerRow,
enableTotalRecordCount = false,
)
- GetItemsRequestHandler
- .execute(api, unwatchedTopRatedRequest)
- .toBaseItems(api, true)
+ GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
+ }.also(jobs::add)
+
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ suggestionService
+ .getSuggestionsFlow(parentId, BaseItemKind.SERIES)
+ .collect { resource ->
+ val state =
+ when (resource) {
+ is SuggestionsResource.Loading -> {
+ HomeRowLoadingState.Loading(
+ context.getString(R.string.suggestions),
+ )
+ }
+
+ is SuggestionsResource.Success -> {
+ HomeRowLoadingState.Success(
+ context.getString(R.string.suggestions),
+ resource.items,
+ )
+ }
+
+ is SuggestionsResource.Empty -> {
+ HomeRowLoadingState.Success(
+ context.getString(R.string.suggestions),
+ emptyList(),
+ )
+ }
+ }
+ update(R.string.suggestions, state)
+ }
+ } catch (ex: CancellationException) {
+ throw ex
+ } catch (ex: Exception) {
+ Timber.e(ex, "Failed to fetch suggestions")
+ update(
+ R.string.suggestions,
+ HomeRowLoadingState.Error(
+ title = context.getString(R.string.suggestions),
+ exception = ex,
+ ),
+ )
+ }
}
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
- loading.setValueOnMain(LoadingState.Success)
+ for (i in 0..
current.toMutableList().apply { set(rowTitles[title]!!, row) }
}
+ return row
}
companion object {
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 d9d69f6a..64cfc78b 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
@@ -40,7 +40,7 @@ fun SliderBar(
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
interval: Int = 1,
- color: Color = MaterialTheme.colorScheme.border,
+ colors: SliderColors = SliderColors.default(),
) {
val isFocused by interactionSource.collectIsFocusedAsState()
val animatedIndicatorHeight by animateDpAsState(
@@ -49,9 +49,6 @@ fun SliderBar(
var currentValue by remember(value) { mutableLongStateOf(value) }
val percent = (currentValue - min).toFloat() / (max - min)
- val activeColor = SliderActiveColor(isFocused)
- val inactiveColor = SliderInactiveColor(isFocused)
-
val handleSeekEventModifier =
Modifier.handleDPadKeyEvents(
triggerOnAction = KeyEvent.ACTION_DOWN,
@@ -91,14 +88,14 @@ fun SliderBar(
onDraw = {
val yOffset = size.height.div(2)
drawLine(
- color = inactiveColor,
+ color = if (isFocused) colors.inactiveFocused else colors.inactiveUnfocused,
start = Offset(x = 0f, y = yOffset),
end = Offset(x = size.width, y = yOffset),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
drawLine(
- color = activeColor,
+ color = if (isFocused) colors.activeFocused else colors.activeUnfocused,
start = Offset(x = 0f, y = yOffset),
end =
Offset(
@@ -119,6 +116,24 @@ fun SliderBar(
}
}
+data class SliderColors(
+ val activeFocused: Color,
+ val activeUnfocused: Color,
+ val inactiveFocused: Color,
+ val inactiveUnfocused: Color,
+) {
+ companion object {
+ @Composable
+ fun default() =
+ SliderColors(
+ activeFocused = SliderActiveColor(true),
+ activeUnfocused = SliderActiveColor(false),
+ inactiveFocused = SliderInactiveColor(true),
+ inactiveUnfocused = SliderInactiveColor(false),
+ )
+ }
+}
+
@Composable
fun SliderActiveColor(focused: Boolean): Color {
val theme = LocalTheme.current
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 f028ed1e..ac72a144 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
@@ -4,6 +4,8 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.gestures.BringIntoViewSpec
+import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
@@ -24,6 +26,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -112,6 +115,7 @@ fun CardGrid(
},
columns: Int = 6,
spacing: Dp = 16.dp,
+ bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
) {
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
@@ -269,100 +273,102 @@ fun CardGrid(
Box(
modifier = Modifier.weight(1f),
) {
- LazyVerticalGrid(
- columns = GridCells.Fixed(columns),
- horizontalArrangement = Arrangement.spacedBy(spacing),
- verticalArrangement = Arrangement.spacedBy(spacing),
- state = gridState,
- contentPadding = PaddingValues(16.dp),
- modifier =
- Modifier
- .fillMaxSize()
- .focusGroup()
- .focusRestorer(firstFocus)
- .focusProperties {
- onExit = {
- // Leaving the grid, so "forget" the position
+ CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(columns),
+ horizontalArrangement = Arrangement.spacedBy(spacing),
+ verticalArrangement = Arrangement.spacedBy(spacing),
+ state = gridState,
+ contentPadding = PaddingValues(vertical = 16.dp),
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .focusGroup()
+ .focusRestorer(firstFocus)
+ .focusProperties {
+ onExit = {
+ // Leaving the grid, so "forget" the position
// focusedIndex = -1
- }
- onEnter = {
- if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
- focusedIndex = startPosition
}
- }
- },
- ) {
- items(pager.size) { index ->
- val mod =
- if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
- if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
- Modifier
- .focusRequester(firstFocus)
- .focusRequester(gridFocusRequester)
- .focusRequester(alphabetFocusRequester)
- } else {
- Modifier
- }
- val item = pager[index]
- cardContent(
- item,
- {
- if (item != null) {
- focusedIndex = index
- onClickItem.invoke(index, item)
- }
- },
- { if (item != null) onLongClickItem.invoke(index, item) },
- mod
- .ifElse(index == 0, Modifier.focusRequester(zeroFocus))
- .onFocusChanged { focusState ->
- if (DEBUG) {
- Timber.v(
- "$index isFocused=${focusState.isFocused}",
- )
+ onEnter = {
+ if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
+ focusedIndex = startPosition
+ }
}
- if (focusState.isFocused) {
- // Focused, so set that up
- focusOn(index)
- positionCallback?.invoke(columns, index)
- } else if (focusedIndex == index) {
+ },
+ ) {
+ items(pager.size) { index ->
+ val mod =
+ if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
+ if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
+ Modifier
+ .focusRequester(firstFocus)
+ .focusRequester(gridFocusRequester)
+ .focusRequester(alphabetFocusRequester)
+ } else {
+ Modifier
+ }
+ val item = pager[index]
+ cardContent(
+ item,
+ {
+ if (item != null) {
+ focusedIndex = index
+ onClickItem.invoke(index, item)
+ }
+ },
+ { if (item != null) onLongClickItem.invoke(index, item) },
+ mod
+ .ifElse(index == 0, Modifier.focusRequester(zeroFocus))
+ .onFocusChanged { focusState ->
+ if (DEBUG) {
+ Timber.v(
+ "$index isFocused=${focusState.isFocused}",
+ )
+ }
+ if (focusState.isFocused) {
+ // Focused, so set that up
+ focusOn(index)
+ positionCallback?.invoke(columns, index)
+ } else if (focusedIndex == index) {
// savedFocusedIndex = index
// // Was focused on this, so mark unfocused
// focusedIndex = -1
- }
- },
- )
+ }
+ },
+ )
+ }
}
- }
- if (pager.isEmpty()) {
+ if (pager.isEmpty()) {
// focusedIndex = -1
- Box(modifier = Modifier.fillMaxSize()) {
- Text(
- text = stringResource(R.string.no_results),
- color = MaterialTheme.colorScheme.onBackground,
- modifier = Modifier.align(Alignment.Center),
- )
+ Box(modifier = Modifier.fillMaxSize()) {
+ Text(
+ text = stringResource(R.string.no_results),
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
}
- }
- if (showFooter) {
- // Footer
- Box(
- modifier =
- Modifier
- .align(Alignment.BottomCenter)
- .background(AppColors.TransparentBlack50),
- ) {
- val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
+ if (showFooter) {
+ // Footer
+ Box(
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .background(AppColors.TransparentBlack50),
+ ) {
+ val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
// if (focusedIndex >= 0) {
// focusedIndex + 1
// } else {
// max(savedFocusedIndex, focusedIndexOnExit) + 1
// }
- Text(
- modifier = Modifier.padding(4.dp),
- color = MaterialTheme.colorScheme.onBackground,
- text = "$index / ${pager.size}",
- )
+ Text(
+ modifier = Modifier.padding(4.dp),
+ color = MaterialTheme.colorScheme.onBackground,
+ text = "$index / ${pager.size}",
+ )
+ }
}
}
}
@@ -394,7 +400,7 @@ fun CardGrid(
modifier =
Modifier
.align(Alignment.CenterVertically)
- .padding(end = 16.dp),
+ .padding(start = 16.dp),
// Add end padding to push away from edge
letterClicked = { letter ->
scope.launch(ExceptionHandler()) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt
index 91cd66f7..07c24c37 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt
@@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail
-import androidx.compose.foundation.layout.padding
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.Modifier
-import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
@@ -40,9 +38,7 @@ fun CollectionFolderBoxSet(
recursive = recursive,
sortOptions = BoxSetSortOptions,
initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
- modifier =
- modifier
- .padding(start = 16.dp),
+ modifier = modifier,
positionCallback = { columns, position ->
showHeader = position < columns
},
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 6db471e3..e0c15c26 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
@@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail
-import androidx.compose.foundation.layout.padding
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.Modifier
-import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
@@ -45,15 +43,15 @@ fun CollectionFolderGeneric(
}
CollectionFolderGrid(
preferences = preferences,
- onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
+ onClickItem = { index, item ->
+ preferencesViewModel.navigationManager.navigateTo(item.destination(index))
+ },
itemId = itemId,
initialFilter = filter,
showTitle = showHeader,
recursive = recursive,
sortOptions = sortOptions,
- modifier =
- modifier
- .padding(start = 16.dp),
+ modifier = modifier,
positionCallback = { columns, position ->
showHeader = position < columns
},
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 6d1fdd02..0d9bd338 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,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
+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
@@ -127,14 +125,14 @@ fun CollectionFolderLiveTv(
) {
AnimatedVisibility(
showHeader,
- enter = slideInVertically() + fadeIn(),
- exit = slideOutVertically() + fadeOut(),
+ enter = expandVertically(),
+ exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
- .padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
+ .padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester),
tabs = tabs.map { it.title },
onClick = { selectedTabIndex = it },
@@ -176,9 +174,7 @@ fun CollectionFolderLiveTv(
showTitle = false,
recursive = false,
sortOptions = VideoSortOptions,
- modifier =
- Modifier
- .padding(start = 16.dp),
+ modifier = Modifier,
positionCallback = { columns, position ->
showHeader = position < columns
},
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 c47aeb23..6f67b011 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,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
+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
@@ -76,14 +74,14 @@ fun CollectionFolderMovie(
) {
AnimatedVisibility(
showHeader,
- enter = slideInVertically() + fadeIn(),
- exit = slideOutVertically() + fadeOut(),
+ enter = expandVertically(),
+ exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
- .padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
+ .padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester),
tabs = tabs,
onClick = { selectedTabIndex = it },
@@ -101,7 +99,6 @@ fun CollectionFolderMovie(
},
modifier =
Modifier
- .padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
)
@@ -129,7 +126,6 @@ fun CollectionFolderMovie(
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
- .padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
@@ -162,7 +158,6 @@ fun CollectionFolderMovie(
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
- .padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
@@ -180,7 +175,6 @@ fun CollectionFolderMovie(
includeItemTypes = listOf(BaseItemKind.MOVIE),
modifier =
Modifier
- .padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
)
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
new file mode 100644
index 00000000..4ddc41c1
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt
@@ -0,0 +1,84 @@
+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.setValue
+import androidx.compose.ui.Modifier
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
+import com.github.damontecres.wholphin.data.filter.ItemFilterBy
+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.ui.components.CollectionFolderGrid
+import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
+import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
+import com.github.damontecres.wholphin.ui.data.SortAndDirection
+import com.github.damontecres.wholphin.ui.data.VideoSortOptions
+import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.toServerString
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ItemSortBy
+import java.util.UUID
+
+@Composable
+fun CollectionFolderPhotoAlbum(
+ preferences: UserPreferences,
+ itemId: UUID,
+ recursive: Boolean,
+ modifier: Modifier = Modifier,
+ filter: CollectionFolderFilter = CollectionFolderFilter(),
+ filterOptions: List> = DefaultFilterOptions,
+ sortOptions: List = VideoSortOptions,
+ // Note: making the VM here and passing down is bad practice, but we need the grid state when clicking on items
+ // TODO refactor this
+ viewModel: CollectionFolderViewModel =
+ hiltViewModel(
+ key = itemId.toServerString(),
+ ) {
+ it.create(
+ itemId = itemId.toServerString(),
+ initialSortAndDirection = null,
+ recursive = recursive,
+ collectionFilter = filter,
+ useSeriesForPrimary = false,
+ defaultViewOptions = ViewOptionsWide,
+ )
+ },
+) {
+ var showHeader by remember { mutableStateOf(true) }
+ CollectionFolderGrid(
+ preferences = preferences,
+ onClickItem = { index, item ->
+ val destination =
+ if (item.type == BaseItemKind.PHOTO) {
+ Destination.Slideshow(
+ parentId = itemId,
+ index = index,
+ filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()),
+ sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT,
+ recursive = true,
+ startSlideshow = false,
+ )
+ } else {
+ item.destination(index)
+ }
+ viewModel.navigateTo(destination)
+ },
+ itemId = itemId.toServerString(),
+ initialFilter = filter,
+ showTitle = showHeader,
+ recursive = recursive,
+ sortOptions = sortOptions,
+ modifier = modifier,
+ positionCallback = { columns, position ->
+ showHeader = position < columns
+ },
+ defaultViewOptions = ViewOptionsWide,
+ playEnabled = true,
+ filterOptions = filterOptions,
+ viewModel = viewModel,
+ )
+}
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 66ace2d7..e7e19066 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
@@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail
-import androidx.compose.foundation.layout.padding
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.Modifier
-import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
@@ -35,9 +33,7 @@ fun CollectionFolderPlaylist(
showTitle = showHeader,
recursive = recursive,
sortOptions = PlaylistSortOptions,
- modifier =
- modifier
- .padding(start = 16.dp),
+ modifier = modifier,
positionCallback = { columns, position ->
showHeader = position < columns
},
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 5362ddb5..a58db1b9 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
@@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail
-import androidx.compose.foundation.layout.padding
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.Modifier
-import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
@@ -35,9 +33,7 @@ fun CollectionFolderRecordings(
showTitle = showHeader,
recursive = recursive,
sortOptions = MovieSortOptions,
- modifier =
- modifier
- .padding(start = 16.dp),
+ modifier = modifier,
positionCallback = { columns, position ->
showHeader = position < columns
},
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 c9fd5381..68b9dd18 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,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
+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
@@ -80,14 +78,14 @@ fun CollectionFolderTv(
) {
AnimatedVisibility(
showHeader,
- enter = slideInVertically() + fadeIn(),
- exit = slideOutVertically() + fadeOut(),
+ enter = expandVertically(),
+ exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
- .padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
+ .padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester),
tabs = tabs,
onClick = { selectedTabIndex = it },
@@ -105,7 +103,6 @@ fun CollectionFolderTv(
},
modifier =
Modifier
- .padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
)
@@ -129,7 +126,6 @@ fun CollectionFolderTv(
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
- .padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
@@ -150,7 +146,6 @@ fun CollectionFolderTv(
includeItemTypes = listOf(BaseItemKind.SERIES),
modifier =
Modifier
- .padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
)
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 f0ad2c0c..cfae65ba 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
@@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.Context
+import android.hardware.display.DisplayManager
import android.os.Build
import android.util.Log
+import android.view.Display
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
@@ -32,11 +34,11 @@ 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.services.RefreshRateService
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
@@ -49,6 +51,7 @@ 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 javax.inject.Inject
@@ -59,13 +62,19 @@ class DebugViewModel
constructor(
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
- val refreshRateService: RefreshRateService,
val clientInfo: ClientInfo,
val deviceInfo: DeviceInfo,
) : ViewModel() {
val itemPlaybacks = MutableLiveData>(listOf())
val logcat = MutableLiveData>(listOf())
+ val supportedModes by lazy {
+ val displayManager =
+ MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
+ val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
+ display.supportedModes.orEmpty()
+ }
+
init {
viewModelScope.launchIO {
serverRepository.currentUser.value?.rowId?.let {
@@ -137,9 +146,13 @@ class DebugViewModel
Send App Logs
clientInfo=$clientInfo
deviceInfo=$deviceInfo
+ manufacturer=${Build.MANUFACTURER}
+ model=${Build.MODEL}
+ apiLevel=${Build.VERSION.SDK_INT}
- """.trimIndent() + logcat
- val response by api.clientLogApi.logFile(body)
+ """.trimIndent()
+ Timber.w(body)
+ val response by api.clientLogApi.logFile(body + logcat)
showToast(context, "Sent! Filename=${response.fileName}")
}
}
@@ -253,8 +266,9 @@ fun DebugPage(
"DeviceInfo: ${viewModel.deviceInfo}",
"Manufacturer: ${Build.MANUFACTURER}",
"Model: ${Build.MODEL}",
+ "API Level: ${Build.VERSION.SDK_INT}",
"Display Modes:",
- *viewModel.refreshRateService.supportedDisplayModes,
+ *viewModel.supportedModes,
).forEach {
Text(
text = it.toString(),
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
index 8a210c27..f34c0228 100644
--- 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
@@ -25,9 +25,11 @@ import kotlin.time.Duration.Companion.seconds
data class MoreDialogActions(
val navigateTo: (Destination) -> Unit,
- var onClickWatch: (UUID, Boolean) -> Unit,
- var onClickFavorite: (UUID, Boolean) -> Unit,
- var onClickAddPlaylist: (UUID) -> Unit,
+ val onClickWatch: (UUID, Boolean) -> Unit,
+ val onClickFavorite: (UUID, Boolean) -> Unit,
+ val onClickAddPlaylist: (UUID) -> Unit,
+ val onSendMediaInfo: (UUID) -> Unit,
+ val onClickDelete: (BaseItem) -> Unit,
)
enum class ClearChosenStreams {
@@ -61,6 +63,7 @@ fun buildMoreDialogItems(
watched: Boolean,
favorite: Boolean,
canClearChosenStreams: Boolean,
+ canDelete: Boolean,
actions: MoreDialogActions,
onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit,
@@ -138,6 +141,17 @@ fun buildMoreDialogItems(
actions.onClickAddPlaylist.invoke(item.id)
},
)
+ if (canDelete) {
+ add(
+ DialogItem(
+ context.getString(R.string.delete),
+ Icons.Default.Delete,
+ iconColor = Color.Red.copy(alpha = .8f),
+ ) {
+ actions.onClickDelete.invoke(item)
+ },
+ )
+ }
add(
DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
@@ -205,6 +219,14 @@ fun buildMoreDialogItems(
)
},
)
+ add(
+ DialogItem(
+ text = R.string.send_media_info_log_to_server,
+ iconStringRes = R.string.fa_file_video,
+ ) {
+ actions.onSendMediaInfo.invoke(item.id)
+ },
+ )
}
fun buildMoreDialogItemsForHome(
@@ -214,6 +236,7 @@ fun buildMoreDialogItemsForHome(
playbackPosition: Duration,
watched: Boolean,
favorite: Boolean,
+ canDelete: Boolean,
actions: MoreDialogActions,
): List =
buildList {
@@ -281,6 +304,17 @@ fun buildMoreDialogItemsForHome(
actions.onClickAddPlaylist.invoke(itemId)
},
)
+ if (canDelete) {
+ add(
+ DialogItem(
+ context.getString(R.string.delete),
+ Icons.Default.Delete,
+ iconColor = Color.Red.copy(alpha = .8f),
+ ) {
+ actions.onClickDelete.invoke(item)
+ },
+ )
+ }
add(
DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
@@ -314,6 +348,14 @@ fun buildMoreDialogItemsForHome(
},
)
}
+ add(
+ DialogItem(
+ text = R.string.send_media_info_log_to_server,
+ iconStringRes = R.string.fa_file_video,
+ ) {
+ actions.onSendMediaInfo.invoke(itemId)
+ },
+ )
}
fun buildMoreDialogItemsForPerson(
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 fc337d88..50b0053e 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,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
+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
@@ -98,8 +96,8 @@ fun FavoritesPage(
) {
AnimatedVisibility(
showHeader,
- enter = slideInVertically() + fadeIn(),
- exit = slideOutVertically() + fadeOut(),
+ enter = expandVertically(),
+ exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
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 17535577..5a01d8a3 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
@@ -298,9 +298,7 @@ fun PersonPageContent(
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
userScrollEnabled = !focusedOnHeader,
- modifier =
- modifier
- .padding(start = 16.dp),
+ modifier = modifier,
) {
item {
PersonHeader(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
index 7e552525..57c8c5d6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
@@ -373,7 +373,6 @@ fun PlaylistDetailsContent(
horizontalArrangement = Arrangement.spacedBy(24.dp),
modifier =
Modifier
- .padding(horizontal = 8.dp)
.fillMaxWidth(),
) {
PlaylistDetailsHeader(
@@ -386,7 +385,7 @@ fun PlaylistDetailsContent(
getPossibleFilterValues = getPossibleFilterValues,
modifier =
Modifier
- .padding(start = 16.dp, top = 80.dp)
+ .padding(top = 80.dp)
.fillMaxWidth(.25f),
)
when (loadingState) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
index 3c22e8a9..a366e788 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
@@ -60,7 +60,6 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
-import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
@@ -102,23 +101,15 @@ fun DiscoverMovieDetails(
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
- val moreActions =
- MoreDialogActions(
- navigateTo = viewModel::navigateTo,
- onClickWatch = { itemId, watched -> },
- onClickFavorite = { itemId, favorite -> },
- onClickAddPlaylist = { itemId -> },
- )
-
when (val state = loading) {
is LoadingState.Error -> {
- ErrorMessage(state)
+ ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
- LoadingPage()
+ LoadingPage(modifier)
}
LoadingState.Success -> {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt
index 79ccb8dd..d8647132 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt
@@ -65,7 +65,7 @@ fun DiscoverMovieDetailsHeader(
) {
val padding = 4.dp
val details =
- remember(movie) {
+ remember(movie, rating) {
buildList {
movie.releaseDate?.let(::add)
movie.runtime
@@ -89,6 +89,7 @@ fun DiscoverMovieDetailsHeader(
?.releaseDates
?.firstOrNull()
?.certification
+ ?.takeIf { it.isNotNullOrBlank() }
?.let(::add)
}.let {
listToDotString(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt
index cc80d404..a50e848d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt
@@ -73,7 +73,8 @@ class DiscoverMovieViewModel
val canCancelRequest = MutableStateFlow(false)
val userConfig = seerrServerRepository.current.map { it?.config }
- val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
+ val request4kEnabled =
+ seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
init {
init()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
index dcaed1c5..3c036534 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
@@ -105,13 +105,13 @@ fun DiscoverSeriesDetails(
when (val state = loading) {
is LoadingState.Error -> {
- ErrorMessage(state)
+ ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
- LoadingPage()
+ LoadingPage(modifier)
}
LoadingState.Success -> {
@@ -463,7 +463,7 @@ fun DiscoverSeriesDetailsHeader(
) {
val padding = 4.dp
val details =
- remember(series) {
+ remember(series, rating) {
buildList {
series.firstAirDate?.let(::add)
series.episodeRunTime
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
index c46fe0cb..ee3cc4a2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
@@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
+import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@@ -82,6 +83,7 @@ fun EpisodeDetails(
var moreDialog by remember { mutableStateOf(null) }
var chooseVersion by remember { mutableStateOf(null) }
var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
+ var showDeleteDialog by remember { mutableStateOf(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val moreActions =
@@ -97,17 +99,19 @@ fun EpisodeDetails(
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
+ onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
+ onClickDelete = { showDeleteDialog = it },
)
when (val state = loading) {
is LoadingState.Error -> {
- ErrorMessage(state)
+ ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
- LoadingPage()
+ LoadingPage(modifier)
}
LoadingState.Success -> {
@@ -214,6 +218,7 @@ fun EpisodeDetails(
onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams)
},
+ canDelete = viewModel.canDelete,
),
)
},
@@ -223,6 +228,8 @@ fun EpisodeDetails(
favoriteOnClick = {
viewModel.setFavorite(ep.id, !ep.favorite)
},
+ canDelete = viewModel.canDelete,
+ deleteOnClick = { showDeleteDialog = ep },
modifier = modifier,
)
}
@@ -275,6 +282,16 @@ fun EpisodeDetails(
elevation = 3.dp,
)
}
+ showDeleteDialog?.let { item ->
+ ConfirmDeleteDialog(
+ itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
+ onCancel = { showDeleteDialog = null },
+ onConfirm = {
+ viewModel.deleteItem(item)
+ showDeleteDialog = null
+ },
+ )
+ }
}
private const val HEADER_ROW = 0
@@ -289,6 +306,8 @@ fun EpisodeDetailsContent(
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit,
+ canDelete: Boolean,
+ deleteOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@@ -303,7 +322,7 @@ fun EpisodeDetailsContent(
Box(modifier = modifier) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
- contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
+ contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize(),
) {
item {
@@ -346,6 +365,8 @@ fun EpisodeDetailsContent(
},
trailers = null,
trailerOnClick = {},
+ canDelete = canDelete,
+ deleteOnClick = deleteOnClick,
modifier =
Modifier
.fillMaxWidth()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
index ee93050e..9b8bda5c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
@@ -12,10 +12,13 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
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.NavigationManager
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.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
@@ -48,10 +51,12 @@ class EpisodeViewModel
val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository,
val streamChoiceService: StreamChoiceService,
+ val mediaReportService: MediaReportService,
private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
+ private val mediaManagementService: MediaManagementService,
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
@@ -63,6 +68,9 @@ class EpisodeViewModel
val item = MutableLiveData(null)
val chosenStreams = MutableLiveData(null)
+ var canDelete: Boolean = false
+ private set
+
init {
init()
}
@@ -93,6 +101,7 @@ class EpisodeViewModel
) {
val prefs = userPreferencesService.getCurrent()
val item = fetchAndSetItem().await()
+ canDelete = mediaManagementService.canDelete(item)
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
withContext(Dispatchers.Main) {
this@EpisodeViewModel.item.value = item
@@ -197,4 +206,10 @@ class EpisodeViewModel
}
}
}
+
+ fun deleteItem(item: BaseItem) {
+ deleteItem(context, mediaManagementService, item) {
+ navigationManager.goBack()
+ }
+ }
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt
index 037a5ae7..30309304 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt
@@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
@@ -140,7 +142,11 @@ fun TvGuideGrid(
.fillMaxHeight(.30f),
)
}
- AnimatedVisibility(focusedPosition.row < 1) {
+ AnimatedVisibility(
+ focusedPosition.row < 1,
+ enter = expandVertically(),
+ exit = shrinkVertically(),
+ ) {
ExpandableFaButton(
title = R.string.view_options,
iconStringRes = R.string.fa_sliders,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
index 58e7762d..a59ecde1 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
@@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
+import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@@ -111,6 +112,7 @@ fun MovieDetails(
var chooseVersion by remember { mutableStateOf(null) }
var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
+ var showDeleteDialog by remember { mutableStateOf(null) }
val moreActions =
MoreDialogActions(
@@ -125,17 +127,19 @@ fun MovieDetails(
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
+ onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
+ onClickDelete = { showDeleteDialog = it },
)
when (val state = loading) {
is LoadingState.Error -> {
- ErrorMessage(state)
+ ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
- LoadingPage()
+ LoadingPage(modifier)
}
LoadingState.Success -> {
@@ -200,6 +204,7 @@ fun MovieDetails(
seriesId = null,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
+ canDelete = viewModel.canDelete,
actions = moreActions,
onChooseVersion = {
chooseVersion =
@@ -290,6 +295,7 @@ fun MovieDetails(
playbackPosition = similar.playbackPosition,
watched = similar.played,
favorite = similar.favorite,
+ canDelete = false,
actions = moreActions,
)
moreDialog =
@@ -309,6 +315,8 @@ fun MovieDetails(
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
},
+ canDelete = viewModel.canDelete,
+ deleteOnClick = { showDeleteDialog = movie },
modifier = modifier,
)
}
@@ -361,6 +369,16 @@ fun MovieDetails(
elevation = 3.dp,
)
}
+ showDeleteDialog?.let { item ->
+ ConfirmDeleteDialog(
+ itemTitle = item.title ?: "",
+ onCancel = { showDeleteDialog = null },
+ onConfirm = {
+ viewModel.deleteItem(item)
+ showDeleteDialog = null
+ },
+ )
+ }
}
private const val HEADER_ROW = 0
@@ -394,6 +412,8 @@ fun MovieDetailsContent(
onLongClickSimilar: (Int, BaseItem) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit,
onClickDiscover: (Int, DiscoverItem) -> Unit,
+ canDelete: Boolean,
+ deleteOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@@ -410,7 +430,7 @@ fun MovieDetailsContent(
Box(modifier = modifier) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
- contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
+ contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize(),
) {
item {
@@ -430,7 +450,7 @@ fun MovieDetailsContent(
modifier =
Modifier
.fillMaxWidth()
- .padding(top = 32.dp, bottom = 16.dp),
+ .padding(top = 40.dp, bottom = 16.dp),
)
ExpandablePlayButtons(
resumePosition = resumePosition,
@@ -456,6 +476,8 @@ fun MovieDetailsContent(
position = TRAILER_ROW
trailerOnClick.invoke(it)
},
+ canDelete = canDelete,
+ deleteOnClick = deleteOnClick,
modifier =
Modifier
.fillMaxWidth()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt
index 60451804..45e3ee54 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt
@@ -46,46 +46,47 @@ fun MovieDetailsHeader(
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
- verticalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
// Title
Text(
text = movie.name ?: "",
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.displaySmall,
- fontWeight = FontWeight.SemiBold,
- maxLines = 2,
+ color = MaterialTheme.colorScheme.onBackground,
+ style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
+ maxLines = 1,
overflow = TextOverflow.Ellipsis,
- modifier = Modifier.fillMaxWidth(.75f),
+ modifier =
+ Modifier
+ .fillMaxWidth(.75f)
+ .padding(start = 8.dp),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f),
) {
- val padding = 4.dp
QuickDetails(
movie.ui.quickDetails,
movie.timeRemainingOrRuntime,
- Modifier.padding(bottom = padding),
+ Modifier.padding(start = 8.dp),
)
dto.genres?.letNotEmpty {
- GenreText(it, Modifier.padding(bottom = padding))
+ GenreText(it, Modifier.padding(start = 8.dp))
}
VideoStreamDetails(
chosenStreams = chosenStreams,
numberOfVersions = movie.data.mediaSourceCount ?: 0,
- modifier = Modifier.padding(bottom = padding),
+ modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 16.dp),
)
dto.taglines?.firstOrNull()?.let { tagline ->
Text(
text = tagline,
style = MaterialTheme.typography.bodyLarge,
fontStyle = FontStyle.Italic,
- modifier = Modifier,
+ modifier = Modifier.padding(start = 8.dp),
)
}
@@ -112,6 +113,7 @@ fun MovieDetailsHeader(
movie.data.people
?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() }
?.joinToString(", ") { it.name!! }
+ ?.takeIf { it.isNotNullOrBlank() }
}
directorName
@@ -120,6 +122,7 @@ fun MovieDetailsHeader(
text = stringResource(R.string.directed_by, it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(start = 8.dp),
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
index 8454f0a1..993260dc 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
@@ -18,6 +18,8 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService
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.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.SeerrService
@@ -25,6 +27,7 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService
+import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
@@ -64,6 +67,7 @@ class MovieViewModel
val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository,
val streamChoiceService: StreamChoiceService,
+ val mediaReportService: MediaReportService,
private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites,
@@ -71,6 +75,7 @@ class MovieViewModel
private val extrasService: ExtrasService,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
+ private val mediaManagementService: MediaManagementService,
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
@@ -88,6 +93,9 @@ class MovieViewModel
val chosenStreams = MutableLiveData(null)
val discovered = MutableStateFlow>(listOf())
+ var canDelete: Boolean = false
+ private set
+
init {
init()
}
@@ -104,6 +112,7 @@ class MovieViewModel
api.userLibraryApi.getItem(itemId).content.let {
BaseItem.from(it, api)
}
+ canDelete = mediaManagementService.canDelete(item)
this@MovieViewModel.item.setValueOnMain(item)
item
}
@@ -272,4 +281,10 @@ class MovieViewModel
}
}
}
+
+ fun deleteItem(item: BaseItem) {
+ deleteItem(context, mediaManagementService, item) {
+ navigationManager.goBack()
+ }
+ }
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt
new file mode 100644
index 00000000..09df419c
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt
@@ -0,0 +1,256 @@
+package com.github.damontecres.wholphin.ui.detail.search
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.focusGroup
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+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.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.FocusDirection
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.ui.Cards
+import com.github.damontecres.wholphin.ui.cards.ItemRow
+import com.github.damontecres.wholphin.ui.cards.SeasonCard
+import com.github.damontecres.wholphin.ui.components.BasicDialog
+import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
+import com.github.damontecres.wholphin.ui.components.VoiceSearchButton
+import com.github.damontecres.wholphin.ui.main.SearchResult
+import kotlinx.coroutines.delay
+import org.jellyfin.sdk.model.api.BaseItemKind
+
+@Composable
+fun SearchForContent(
+ searchType: BaseItemKind,
+ onClick: (BaseItem) -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SearchForViewModel = hiltViewModel(key = searchType.serialName),
+) {
+ val focusManager = LocalFocusManager.current
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val state by viewModel.state.collectAsState()
+
+ var query by rememberSaveable { mutableStateOf("") }
+ val searchFocusRequester = remember { FocusRequester() }
+ val focusRequester = remember { FocusRequester() }
+
+ var immediateSearchQuery by rememberSaveable { mutableStateOf(null) }
+
+ LifecycleResumeEffect(Unit) {
+ onPauseOrDispose {
+ viewModel.voiceInputManager.stopListening()
+ }
+ }
+
+ fun triggerImmediateSearch(searchQuery: String) {
+ immediateSearchQuery = searchQuery
+ viewModel.search(searchType, searchQuery)
+ }
+
+ LaunchedEffect(query) {
+ when {
+ immediateSearchQuery == query -> {
+ immediateSearchQuery = null
+ }
+
+ else -> {
+ delay(750L)
+ viewModel.search(searchType, query)
+ }
+ }
+ }
+ val titleRes =
+ remember {
+ when (searchType) {
+ BaseItemKind.BOX_SET -> R.string.collections
+ BaseItemKind.PLAYLIST -> R.string.playlists
+ else -> null
+ }
+ }
+ val title = titleRes?.let { stringResource(it) } ?: ""
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = modifier,
+ ) {
+ Text(
+ text = stringResource(R.string.search_for, title),
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ var isSearchActive by remember { mutableStateOf(false) }
+ var isTextFieldFocused by remember { mutableStateOf(false) }
+ val textFieldFocusRequester = remember { FocusRequester() }
+
+ BackHandler(isTextFieldFocused) {
+ when {
+ isSearchActive -> {
+ isSearchActive = false
+ keyboardController?.hide()
+ }
+
+ else -> {
+ focusManager.moveFocus(FocusDirection.Next)
+ }
+ }
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier
+ .focusGroup()
+ .focusRestorer(textFieldFocusRequester)
+ .focusRequester(searchFocusRequester),
+ ) {
+ VoiceSearchButton(
+ onSpeechResult = { spokenText ->
+ query = spokenText
+ triggerImmediateSearch(spokenText)
+ },
+ voiceInputManager = viewModel.voiceInputManager,
+ )
+
+ SearchEditTextBox(
+ value = query,
+ onValueChange = {
+ isSearchActive = true
+ query = it
+ },
+ onSearchClick = { triggerImmediateSearch(query) },
+ readOnly = !isSearchActive,
+ modifier =
+ Modifier
+ .focusRequester(textFieldFocusRequester)
+ .onFocusChanged { state ->
+ isTextFieldFocused = state.isFocused
+ if (!state.isFocused) isSearchActive = false
+ }.onPreviewKeyEvent { event ->
+ val isActivationKey =
+ event.key in listOf(Key.DirectionCenter, Key.Enter)
+ if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) {
+ isSearchActive = true
+ keyboardController?.show()
+ true
+ } else {
+ false
+ }
+ },
+ )
+ }
+ }
+
+ when (val st = state.results) {
+ is SearchResult.Error -> {
+ ErrorMessage("Error", st.ex)
+ }
+
+ SearchResult.NoQuery -> {
+ // no-op
+ }
+
+ SearchResult.Searching -> {
+ Text(
+ text = stringResource(R.string.searching),
+ )
+ }
+
+ is SearchResult.SuccessSeerr -> {
+ Text(
+ text = "Not supported",
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ is SearchResult.Success -> {
+ if (st.items.isEmpty()) {
+ Text(
+ text = stringResource(R.string.no_results),
+ )
+ } else {
+ ItemRow(
+ title = "",
+ items = st.items,
+ onClickItem = { _, item -> onClick.invoke(item) },
+ onLongClickItem = { _, _ -> },
+ modifier = Modifier.focusRequester(focusRequester),
+ cardContent = { index, item, mod, onClick, onLongClick ->
+ SeasonCard(
+ item = item,
+ onClick = {
+ onClick.invoke()
+ },
+ onLongClick = onLongClick,
+ imageHeight = Cards.height2x3,
+ modifier = mod,
+ )
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SearchForDialog(
+ onDismissRequest: () -> Unit,
+ searchType: BaseItemKind,
+ onClick: (BaseItem) -> Unit,
+) {
+ BasicDialog(
+ onDismissRequest = onDismissRequest,
+ properties =
+ DialogProperties(
+ usePlatformDefaultWidth = false,
+ ),
+ ) {
+ SearchForContent(
+ searchType = searchType,
+ onClick = onClick,
+ modifier =
+ Modifier
+ .padding(8.dp)
+ .fillMaxWidth(.8f)
+ .fillMaxHeight(.66f),
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt
new file mode 100644
index 00000000..baa98ef1
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt
@@ -0,0 +1,71 @@
+package com.github.damontecres.wholphin.ui.detail.search
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.ui.SlimItemFields
+import com.github.damontecres.wholphin.ui.components.VoiceInputManager
+import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.main.SearchResult
+import com.github.damontecres.wholphin.util.ApiRequestPager
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltViewModel
+class SearchForViewModel
+ @Inject
+ constructor(
+ private val api: ApiClient,
+ private val serverRepository: ServerRepository,
+ val navigationManager: NavigationManager,
+ val voiceInputManager: VoiceInputManager,
+ ) : ViewModel() {
+ val state = MutableStateFlow(SearchForState())
+
+ init {
+ state.value = SearchForState()
+ }
+
+ fun search(
+ searchType: BaseItemKind,
+ query: String,
+ ) {
+ viewModelScope.launchIO {
+ if (state.value.query != query) {
+ if (query.isBlank()) {
+ state.update { SearchForState(query, SearchResult.NoQuery) }
+ return@launchIO
+ }
+ state.update { SearchForState(query, SearchResult.Searching) }
+ try {
+ val request =
+ GetItemsRequest(
+ userId = serverRepository.currentUser.value?.id,
+ searchTerm = query,
+ includeItemTypes = listOf(searchType),
+ recursive = true,
+ fields = SlimItemFields,
+ )
+ val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
+ state.update { SearchForState(query, SearchResult.Success(pager)) }
+ } catch (ex: Exception) {
+ Timber.e(ex)
+ state.update { SearchForState(query, SearchResult.Error(ex)) }
+ }
+ }
+ }
+ }
+ }
+
+data class SearchForState(
+ val query: String = "",
+ val results: SearchResult = SearchResult.NoQuery,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt
index 9b6d81df..2aeac04e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt
@@ -25,6 +25,8 @@ fun FocusedEpisodeFooter(
moreOnClick: () -> Unit,
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
+ canDelete: Boolean,
+ deleteOnClick: () -> Unit,
modifier: Modifier = Modifier,
buttonOnFocusChanged: (FocusState) -> Unit = {},
) {
@@ -47,6 +49,8 @@ fun FocusedEpisodeFooter(
buttonOnFocusChanged = buttonOnFocusChanged,
trailers = null,
trailerOnClick = {},
+ canDelete = canDelete,
+ deleteOnClick = deleteOnClick,
modifier = Modifier.fillMaxWidth(),
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt
index ff0ddade..aab6e056 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt
@@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusState
@@ -31,17 +32,17 @@ fun FocusedEpisodeHeader(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
- EpisodeName(dto, modifier = Modifier)
+ EpisodeName(dto, modifier = Modifier.padding(start = 8.dp))
ep?.ui?.quickDetails?.let {
- QuickDetails(it, ep.timeRemainingOrRuntime)
+ QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp))
}
if (dto != null) {
VideoStreamDetails(
chosenStreams = chosenStreams,
numberOfVersions = dto.mediaSourceCount ?: 0,
- modifier = Modifier,
+ modifier = Modifier.padding(start = 8.dp),
)
}
OverviewText(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
index 305c6451..81beb82f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
@@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -24,13 +25,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -52,7 +56,9 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
+import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
+import com.github.damontecres.wholphin.ui.components.DeleteButton
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
@@ -75,6 +81,7 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
@@ -101,31 +108,35 @@ fun SeriesDetails(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val focusManager = LocalFocusManager.current
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState()
+ val canDelete by viewModel.canDeleteSeries.collectAsState()
val seasons by viewModel.seasons.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val extras by viewModel.extras.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
val discovered by viewModel.discovered.collectAsState()
+ val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var overviewDialog by remember { mutableStateOf(null) }
var showWatchConfirmation by remember { mutableStateOf(false) }
var seasonDialog by remember { mutableStateOf(null) }
var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
- val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
+ var showDeleteDialog by remember { mutableStateOf(null) }
when (val state = loading) {
is LoadingState.Error -> {
- ErrorMessage(state)
+ ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
- LoadingPage()
+ LoadingPage(modifier)
}
LoadingState.Success -> {
@@ -149,6 +160,7 @@ fun SeriesDetails(
similar = similar,
played = played,
favorite = item.data.userData?.isFavorite ?: false,
+ canDelete = canDelete,
modifier = modifier,
onClickItem = { index, item ->
viewModel.navigateTo(item.destination())
@@ -162,23 +174,29 @@ fun SeriesDetails(
)
},
onLongClickItem = { index, season ->
- seasonDialog =
- buildDialogForSeason(
- context = context,
- s = season,
- onClickItem = { viewModel.navigateTo(it.destination()) },
- markPlayed = { played ->
- viewModel.setSeasonWatched(season.id, played)
- },
- onClickPlay = { shuffle ->
- viewModel.navigateTo(
- Destination.PlaybackList(
- itemId = season.id,
- shuffle = shuffle,
- ),
- )
- },
- )
+ scope.launchDefault {
+ seasonDialog =
+ buildDialogForSeason(
+ context = context,
+ s = season,
+ canDelete = viewModel.canDelete(season),
+ onClickItem = { viewModel.navigateTo(it.destination()) },
+ markPlayed = { played ->
+ viewModel.setSeasonWatched(season.id, played)
+ },
+ onClickPlay = { shuffle ->
+ viewModel.navigateTo(
+ Destination.PlaybackList(
+ itemId = season.id,
+ shuffle = shuffle,
+ ),
+ )
+ },
+ onClickDelete = {
+ showDeleteDialog = it
+ },
+ )
+ }
},
overviewOnClick = {
overviewDialog =
@@ -229,6 +247,10 @@ fun SeriesDetails(
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
+ onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
+ onClickDelete = {
+ showDeleteDialog = it
+ },
),
)
if (showWatchConfirmation) {
@@ -281,6 +303,19 @@ fun SeriesDetails(
elevation = 3.dp,
)
}
+ showDeleteDialog?.let { item ->
+ ConfirmDeleteDialog(
+ itemTitle = item.title ?: "",
+ onCancel = { showDeleteDialog = null },
+ onConfirm = {
+ if (seasons?.lastOrNull()?.id == item.id) {
+ focusManager.moveFocus(FocusDirection.Previous)
+ }
+ viewModel.deleteItem(item)
+ showDeleteDialog = null
+ },
+ )
+ }
}
private const val HEADER_ROW = 0
@@ -303,6 +338,7 @@ fun SeriesDetailsContent(
discovered: List,
played: Boolean,
favorite: Boolean,
+ canDelete: Boolean,
onClickItem: (Int, BaseItem) -> Unit,
onClickPerson: (Person) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
@@ -332,12 +368,12 @@ fun SeriesDetailsContent(
Column(
modifier =
Modifier
- .padding(16.dp)
+ .padding(vertical = 16.dp)
.fillMaxSize(),
) {
LazyColumn(
contentPadding = PaddingValues(bottom = 80.dp),
- verticalArrangement = Arrangement.spacedBy(0.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier,
) {
item {
@@ -354,7 +390,7 @@ fun SeriesDetailsContent(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
- .padding(start = 16.dp)
+ .padding(start = 8.dp)
.focusRequester(focusRequesters[HEADER_ROW])
.focusRestorer(playFocusRequester)
.focusGroup()
@@ -434,6 +470,23 @@ fun SeriesDetailsContent(
}
},
)
+ if (canDelete) {
+ DeleteButton(
+ onClick = {
+ position = HEADER_ROW
+ moreActions.onClickDelete.invoke(series)
+ },
+ modifier =
+ Modifier
+ .onFocusChanged {
+ if (it.isFocused) {
+ scope.launch(ExceptionHandler()) {
+ bringIntoViewRequester.bringIntoView()
+ }
+ }
+ },
+ )
+ }
}
}
item {
@@ -531,6 +584,7 @@ fun SeriesDetailsContent(
watched = item.played,
favorite = item.favorite,
actions = moreActions,
+ canDelete = false,
)
moreDialog =
DialogParams(
@@ -599,23 +653,27 @@ fun SeriesDetailsHeader(
val scope = rememberCoroutineScope()
val dto = series.data
Column(
- verticalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Text(
text = series.name ?: stringResource(R.string.unknown),
- style = MaterialTheme.typography.displaySmall,
- maxLines = 2,
+ color = MaterialTheme.colorScheme.onBackground,
+ style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
+ maxLines = 1,
overflow = TextOverflow.Ellipsis,
- modifier = Modifier.fillMaxWidth(.75f),
+ modifier =
+ Modifier
+ .fillMaxWidth(.75f)
+ .padding(start = 8.dp),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f),
) {
- QuickDetails(series.ui.quickDetails, null)
+ QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp))
dto.genres?.letNotEmpty {
- GenreText(it)
+ GenreText(it, Modifier.padding(start = 8.dp, bottom = 8.dp))
}
dto.overview?.let { overview ->
OverviewText(
@@ -632,9 +690,11 @@ fun SeriesDetailsHeader(
fun buildDialogForSeason(
context: Context,
s: BaseItem,
+ canDelete: Boolean,
onClickItem: (BaseItem) -> Unit,
markPlayed: (Boolean) -> Unit,
onClickPlay: (Boolean) -> Unit,
+ onClickDelete: (BaseItem) -> Unit,
): DialogParams {
val items =
buildList {
@@ -673,6 +733,17 @@ fun buildDialogForSeason(
onClickPlay.invoke(true)
},
)
+ if (canDelete) {
+ add(
+ DialogItem(
+ context.getString(R.string.delete),
+ Icons.Default.Delete,
+ iconColor = Color.Red.copy(alpha = .8f),
+ ) {
+ onClickDelete.invoke(s)
+ },
+ )
+ }
}
return DialogParams(
title = s.name ?: context.getString(R.string.tv_season),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt
index d95a5bbb..3ed615e9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt
@@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -23,6 +24,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
+import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@@ -36,9 +38,11 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.seasonEpisode
+import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable
@@ -89,6 +93,7 @@ fun SeriesOverview(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
+ val scope = rememberCoroutineScope()
val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() }
val castCrewRowFocusRequester = remember { FocusRequester() }
@@ -118,6 +123,7 @@ fun SeriesOverview(
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var rowFocused by rememberInt()
+ var showDeleteDialog by remember { mutableStateOf(null) }
LaunchedEffect(episodes) {
episodes?.let { episodes ->
@@ -148,13 +154,13 @@ fun SeriesOverview(
when (val state = loading) {
is LoadingState.Error -> {
- ErrorMessage(state)
+ ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
- LoadingPage()
+ LoadingPage(modifier)
}
LoadingState.Success -> {
@@ -177,7 +183,7 @@ fun SeriesOverview(
}
}
- fun buildMoreForEpisode(
+ suspend fun buildMoreForEpisode(
ep: BaseItem,
chosenStreams: ChosenStreams?,
fromLongClick: Boolean,
@@ -194,6 +200,7 @@ fun SeriesOverview(
seriesId = series.id,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
+ canDelete = viewModel.canDelete(ep),
actions =
MoreDialogActions(
navigateTo = viewModel::navigateTo,
@@ -215,6 +222,10 @@ fun SeriesOverview(
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = it
},
+ onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
+ onClickDelete = {
+ showDeleteDialog = it
+ },
),
onChooseVersion = {
chooseVersion =
@@ -314,7 +325,9 @@ fun SeriesOverview(
)
},
onLongClick = { ep ->
- moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
+ scope.launchDefault {
+ moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
+ }
},
playOnClick = { resume ->
rowFocused = EPISODE_ROW
@@ -342,18 +355,22 @@ fun SeriesOverview(
},
moreOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
- moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
+ scope.launchDefault {
+ moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
+ }
}
},
overviewOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
- overviewDialog =
- ItemDetailsDialogInfo(
- title = it.name ?: context.getString(R.string.unknown),
- overview = it.data.overview,
- genres = it.data.genres.orEmpty(),
- files = it.data.mediaSources.orEmpty(),
- )
+ scope.launchDefault {
+ overviewDialog =
+ ItemDetailsDialogInfo(
+ title = it.name ?: context.getString(R.string.unknown),
+ overview = it.data.overview,
+ genres = it.data.genres.orEmpty(),
+ files = it.data.mediaSources.orEmpty(),
+ )
+ }
}
},
personOnClick = {
@@ -366,6 +383,8 @@ fun SeriesOverview(
),
)
},
+ canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
+ deleteOnClick = { showDeleteDialog = it },
modifier = modifier,
)
}
@@ -419,6 +438,17 @@ fun SeriesOverview(
elevation = 3.dp,
)
}
+ showDeleteDialog?.let { item ->
+ ConfirmDeleteDialog(
+ itemTitle = item.subtitle ?: "",
+ onCancel = { showDeleteDialog = null },
+ onConfirm = {
+ viewModel.deleteItem(item)
+ episodeRowFocusRequester.tryRequestFocus()
+ showDeleteDialog = null
+ },
+ )
+ }
}
private const val EPISODE_ROW = 0
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt
index b19e7900..8acc32f1 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt
@@ -88,6 +88,8 @@ fun SeriesOverviewContent(
moreOnClick: () -> Unit,
overviewOnClick: () -> Unit,
personOnClick: (Person) -> Unit,
+ canDelete: (BaseItem) -> Boolean,
+ deleteOnClick: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@@ -127,14 +129,14 @@ fun SeriesOverviewContent(
modifier =
Modifier
.fillMaxSize()
- .padding(16.dp)
+ .padding(vertical = 16.dp)
.focusGroup()
.nestedScroll(scrollConnection)
.verticalScroll(scrollState)
.onFocusChanged { pageHasFocus = it.hasFocus },
) {
Column(
- verticalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
modifier =
Modifier
.focusGroup()
@@ -142,9 +144,9 @@ fun SeriesOverviewContent(
) {
val paddingValues =
if (preferences.appPreferences.interfacePreferences.showClock) {
- PaddingValues(start = 16.dp, end = 100.dp)
+ PaddingValues(start = 0.dp, end = 184.dp)
} else {
- PaddingValues(start = 16.dp, end = 16.dp)
+ PaddingValues(start = 0.dp, end = 16.dp)
}
TabRow(
selectedTabIndex = selectedTabIndex,
@@ -159,9 +161,10 @@ fun SeriesOverviewContent(
Modifier
.focusRequester(tabRowFocusRequester)
.padding(paddingValues)
+ .padding(bottom = 4.dp)
.fillMaxWidth(),
)
- SeriesName(series.name, Modifier)
+ SeriesName(series.name, Modifier.padding(start = 8.dp))
FocusedEpisodeHeader(
preferences = preferences,
ep = focusedEpisode,
@@ -266,6 +269,7 @@ fun SeriesOverviewContent(
},
interactionSource = interactionSource,
cardHeight = 120.dp,
+ useSeriesForPrimary = false,
)
}
}
@@ -292,10 +296,12 @@ fun SeriesOverviewContent(
}
}
},
+ canDelete = canDelete.invoke(ep),
+ deleteOnClick = { deleteOnClick.invoke(ep) },
modifier =
Modifier
- .fillMaxWidth()
- .padding(start = 16.dp),
+ .padding(top = 4.dp)
+ .fillMaxWidth(),
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
index 8ae7a579..53924431 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
@@ -13,9 +13,12 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
+import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService
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.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.SeerrService
@@ -23,10 +26,12 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService
+import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.gt
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.lt
@@ -52,6 +57,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -85,9 +93,11 @@ class SeriesViewModel
private val trailerService: TrailerService,
private val extrasService: ExtrasService,
val streamChoiceService: StreamChoiceService,
+ val mediaReportService: MediaReportService,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
private val seerrService: SeerrService,
+ private val mediaManagementService: MediaManagementService,
@Assisted val seriesId: UUID,
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
@Assisted val seriesPageType: SeriesPageType,
@@ -109,6 +119,7 @@ class SeriesViewModel
val extras = MutableLiveData>(listOf())
val people = MutableLiveData>(listOf())
val similar = MutableLiveData>()
+ val canDeleteSeries = MutableStateFlow(false)
val peopleInEpisode = MutableLiveData(PeopleInItem())
val discovered = MutableStateFlow>(listOf())
@@ -125,6 +136,7 @@ class SeriesViewModel
Timber.v("Start")
addCloseable { themeSongPlayer.stop() }
val item = fetchItem(seriesId)
+ canDeleteSeries.update { mediaManagementService.canDelete(item) }
backdropService.submit(item)
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
@@ -221,6 +233,20 @@ class SeriesViewModel
discovered.update { results }
}
}
+ mediaManagementService.deletedItemFlow
+ .onEach { deletedItem ->
+ if (deletedItem.item.data.seriesId == seriesId) {
+ Timber.d(
+ "Item %s deleted from series %s",
+ deletedItem.item.id,
+ seriesId,
+ )
+ val seasons = getSeasons(item, seasonEpisodeIds?.seasonNumber).await()
+ this@SeriesViewModel.seasons.setValueOnMain(seasons)
+ }
+ }.catch { ex ->
+ Timber.e(ex, "Error refreshing after deleted item")
+ }.launchIn(viewModelScope)
}
}
@@ -257,9 +283,12 @@ class SeriesViewModel
if (seriesPageType == SeriesPageType.DETAILS) {
listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
+ ItemFields.CAN_DELETE,
)
} else {
- null
+ listOf(
+ ItemFields.CAN_DELETE,
+ )
},
)
val pager =
@@ -298,6 +327,7 @@ class SeriesViewModel
ItemFields.OVERVIEW,
ItemFields.CUSTOM_RATING,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
+ ItemFields.CAN_DELETE,
),
)
Timber.v(
@@ -526,7 +556,7 @@ class SeriesViewModel
api.userLibraryApi
.getItem(item.id)
.content.people
- ?.map { Person.fromDto(it, api) }
+ ?.map { Person.fromDto(context, it, api) }
.orEmpty()
PeopleInItem(item.id, list)
@@ -549,6 +579,64 @@ class SeriesViewModel
lookUpChosenTracks(item.id, item)
}
}
+
+ fun deleteItem(item: BaseItem) {
+ deleteItem(context, mediaManagementService, item) {
+ viewModelScope.launchDefault {
+ if (item.type == BaseItemKind.SERIES) {
+ navigationManager.goBack()
+ } else if (seriesPageType == SeriesPageType.DETAILS) {
+ this@SeriesViewModel.item.value?.let { series ->
+ val seasons = getSeasons(series, null).await()
+ if (seasons.isEmpty()) {
+ navigationManager.goBack()
+ } else {
+ this@SeriesViewModel.seasons.setValueOnMain(seasons)
+ }
+ }
+ } else {
+ position.value.let { (_, episodeIndex) ->
+ val eps = episodes.value as? EpisodeList.Success
+ if (eps != null) {
+ val pager = eps.episodes
+ val lastIndex = pager.lastIndex
+ pager.refreshPagesAfter(episodeIndex)
+ if (pager.isEmpty()) {
+ navigationManager.goBack()
+ } else {
+ if (episodeIndex == lastIndex) {
+ // Deleted last episode, so need to move left
+ episodes.setValueOnMain(
+ EpisodeList.Success(
+ eps.seasonId,
+ pager,
+ episodeIndex - 1,
+ ),
+ )
+ position.update { it.copy(episodeRowIndex = episodeIndex - 1) }
+ } else {
+ episodes.setValueOnMain(
+ EpisodeList.Success(
+ eps.seasonId,
+ pager,
+ episodeIndex,
+ ),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item)
+
+ fun canDelete(
+ item: BaseItem,
+ appPreferences: AppPreferences,
+ ): Boolean = mediaManagementService.canDelete(item, appPreferences)
}
sealed interface EpisodeList {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt
index 53b04161..5c18a0ea 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt
@@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.discover
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
+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
@@ -60,8 +58,8 @@ fun DiscoverPage(
) {
AnimatedVisibility(
showHeader,
- enter = slideInVertically() + fadeIn(),
- exit = slideOutVertically() + fadeOut(),
+ enter = expandVertically(),
+ exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt
index 691b00cb..97b974ff 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt
@@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.ui.discover
import android.content.Context
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -23,6 +26,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -47,6 +51,7 @@ import com.github.damontecres.wholphin.ui.listToDotString
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.DataLoadingState
import com.google.common.cache.CacheBuilder
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -186,6 +191,7 @@ data class DiscoverState(
val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY,
)
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SeerrDiscoverPage(
preferences: UserPreferences,
@@ -249,28 +255,41 @@ fun SeerrDiscoverPage(
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
.fillMaxHeight(.25f),
)
- LazyColumn(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp),
- modifier =
- Modifier
- .focusRestorer()
- .fillMaxSize(),
+ val density = LocalDensity.current
+ val spaceAbovePx =
+ with(density) {
+ // The size of the row titles & spacing
+ 50.dp.toPx()
+ }
+ val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
+ CompositionLocalProvider(
+ LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
) {
- itemsIndexed(rows) { rowIndex, row ->
- DiscoverRow(
- row = row,
- onClickItem = { index, item ->
- position = RowColumn(rowIndex, index)
- viewModel.navigationManager.navigateTo(item.destination)
- },
- onLongClickItem = { index, item -> },
- onCardFocus = { index -> position = RowColumn(rowIndex, index) },
- focusRequester = focusRequesters[rowIndex],
- modifier =
- Modifier
- .fillMaxWidth(),
- )
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp),
+ modifier =
+ Modifier
+ .focusRestorer()
+ .fillMaxSize(),
+ ) {
+ itemsIndexed(rows) { rowIndex, row ->
+ CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
+ DiscoverRow(
+ row = row,
+ onClickItem = { index, item ->
+ position = RowColumn(rowIndex, index)
+ viewModel.navigationManager.navigateTo(item.destination)
+ },
+ onLongClickItem = { index, item -> },
+ onCardFocus = { index -> position = RowColumn(rowIndex, index) },
+ focusRequester = focusRequesters[rowIndex],
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ )
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt
index 3a838815..85863650 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt
@@ -64,7 +64,7 @@ class SeerrRequestsViewModel
viewModelScope.launchIO {
backdropService.clearBackdrop()
}
- seerrServerRepository.current
+ seerrServerRepository.connection
.onEach { user ->
state.update { it.copy(requests = DataLoadingState.Loading) }
if (user != null) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
index 4c58b00c..3a7a457a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
@@ -1,8 +1,8 @@
package com.github.damontecres.wholphin.ui.main
-import androidx.compose.foundation.background
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusGroup
-import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -16,10 +16,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -31,9 +34,9 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -44,18 +47,23 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.UserPreferences
-import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard
+import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
+import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress
+import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeName
import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.FocusableItemRow
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.QuickDetails
+import com.github.damontecres.wholphin.ui.components.RowColumnItem
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
@@ -67,8 +75,10 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
import com.github.damontecres.wholphin.ui.playback.playable
+import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.delay
@@ -89,34 +99,38 @@ fun HomePage(
LaunchedEffect(Unit) {
viewModel.init()
}
- val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
- val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading)
- val watchingRows by viewModel.watchingRows.observeAsState(listOf())
- val latestRows by viewModel.latestRows.observeAsState(listOf())
-
- val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows }
+ val state by viewModel.state.collectAsState()
+ val loading = state.loadingState
+ val refreshing = state.refreshState
+ val homeRows = state.homeRows
when (val state = loading) {
is LoadingState.Error -> {
- ErrorMessage(state)
+ ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
- LoadingPage()
+ LoadingPage(modifier)
}
LoadingState.Success -> {
var dialog by remember { mutableStateOf(null) }
var showPlaylistDialog by remember { mutableStateOf(null) }
+ var showDeleteDialog by remember { mutableStateOf(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
+ var position by rememberPosition()
HomePageContent(
homeRows = homeRows,
- onClickItem = { position, item ->
+ position = position,
+ onFocusPosition = { position = it },
+ onClickItem = { clickedPosition, item ->
+ position = clickedPosition
viewModel.navigationManager.navigateTo(item.destination())
},
- onLongClickItem = { position, item ->
+ onLongClickItem = { clickedPosition, item ->
+ position = clickedPosition
val dialogItems =
buildMoreDialogItemsForHome(
context = context,
@@ -125,6 +139,7 @@ fun HomePage(
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
+ canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions =
MoreDialogActions(
navigateTo = viewModel.navigationManager::navigateTo,
@@ -138,6 +153,10 @@ fun HomePage(
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = itemId
},
+ onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
+ onClickDelete = {
+ showDeleteDialog = RowColumnItem(position, item)
+ },
),
)
dialog =
@@ -178,47 +197,62 @@ fun HomePage(
elevation = 3.dp,
)
}
+ showDeleteDialog?.let { (position, item) ->
+ ConfirmDeleteDialog(
+ itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
+ onCancel = { showDeleteDialog = null },
+ onConfirm = {
+ viewModel.deleteItem(position, item)
+ showDeleteDialog = null
+ },
+ )
+ }
}
}
}
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HomePageContent(
homeRows: List,
+ position: RowColumn,
+ onFocusPosition: (RowColumn) -> Unit,
onClickItem: (RowColumn, BaseItem) -> Unit,
onLongClickItem: (RowColumn, BaseItem) -> Unit,
onClickPlay: (RowColumn, BaseItem) -> Unit,
showClock: Boolean,
onUpdateBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
- onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null,
+ listState: LazyListState = rememberLazyListState(),
+ takeFocus: Boolean = true,
+ showEmptyRows: Boolean = false,
) {
- var position by rememberPosition()
val focusedItem =
position.let {
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
}
- val listState = rememberLazyListState()
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
var firstFocused by remember { mutableStateOf(false) }
- LaunchedEffect(homeRows) {
- if (!firstFocused && homeRows.isNotEmpty()) {
- if (position.row >= 0) {
- val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
- rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
- firstFocused = true
- } else {
- // Waiting for the first home row to load, then focus on it
- homeRows
- .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
- ?.let {
- rowFocusRequesters[it].tryRequestFocus()
- firstFocused = true
- delay(50)
- listState.scrollToItem(it)
- }
+ if (takeFocus) {
+ LaunchedEffect(homeRows) {
+ if (!firstFocused && homeRows.isNotEmpty()) {
+ if (position.row >= 0) {
+ val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
+ rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
+ firstFocused = true
+ } else {
+ // Waiting for the first home row to load, then focus on it
+ homeRows
+ .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
+ ?.let {
+ rowFocusRequesters[it].tryRequestFocus()
+ firstFocused = true
+ delay(50)
+ listState.scrollToItem(it)
+ }
+ }
}
}
}
@@ -236,140 +270,113 @@ fun HomePageContent(
item = focusedItem,
modifier =
Modifier
- .padding(top = 48.dp, bottom = 32.dp, start = 32.dp)
+ .padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
.fillMaxHeight(.33f),
)
- LazyColumn(
- state = listState,
- verticalArrangement = Arrangement.spacedBy(8.dp),
- contentPadding =
- PaddingValues(
- start = 16.dp,
- end = 16.dp,
- top = 0.dp,
- bottom = Cards.height2x3,
- ),
- modifier =
- Modifier
- .focusRestorer(),
+ val density = LocalDensity.current
+ val spaceAbovePx =
+ with(density) {
+ // The size of the row titles & spacing
+ 50.dp.toPx()
+ }
+ val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
+ CompositionLocalProvider(
+ LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
) {
- itemsIndexed(homeRows) { rowIndex, row ->
- when (val r = row) {
- is HomeRowLoadingState.Loading,
- is HomeRowLoadingState.Pending,
- -> {
- Column(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.animateItem(),
- ) {
- Text(
- text = r.title,
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onBackground,
- )
- Text(
- text = stringResource(R.string.loading),
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onBackground,
- )
- }
- }
+ LazyColumn(
+ state = listState,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding =
+ PaddingValues(
+ bottom = Cards.height2x3,
+ ),
+ modifier =
+ Modifier
+ .focusRestorer(),
+ ) {
+ itemsIndexed(homeRows) { rowIndex, row ->
+ CompositionLocalProvider(
+ LocalBringIntoViewSpec provides defaultBringIntoViewSpec,
+ ) {
+ when (val r = row) {
+ is HomeRowLoadingState.Loading,
+ is HomeRowLoadingState.Pending,
+ -> {
+ FocusableItemRow(
+ title = r.title,
+ subtitle = stringResource(R.string.loading),
+ modifier = Modifier.animateItem(),
+ )
+ }
- is HomeRowLoadingState.Error -> {
- var focused by remember { mutableStateOf(false) }
- Column(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier =
- Modifier
- .onFocusChanged {
- focused = it.isFocused
- }.focusable()
- .background(
- if (focused) {
- // Just so the user can tell it has focus
- MaterialTheme.colorScheme.border.copy(alpha = .25f)
- } else {
- Color.Unspecified
+ is HomeRowLoadingState.Error -> {
+ FocusableItemRow(
+ title = r.title,
+ subtitle = r.localizedMessage,
+ isError = true,
+ modifier = Modifier.animateItem(),
+ )
+ }
+
+ is HomeRowLoadingState.Success -> {
+ if (row.items.isNotEmpty()) {
+ val viewOptions = row.viewOptions
+ ItemRow(
+ title = row.title,
+ items = row.items,
+ onClickItem = { index, item ->
+ onClickItem.invoke(RowColumn(rowIndex, index), item)
+ },
+ onLongClickItem = { index, item ->
+ onLongClickItem.invoke(
+ RowColumn(rowIndex, index),
+ item,
+ )
},
- ).animateItem(),
- ) {
- Text(
- text = r.title,
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onBackground,
- )
- Text(
- text = r.localizedMessage,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.error,
- )
- }
- }
-
- is HomeRowLoadingState.Success -> {
- if (row.items.isNotEmpty()) {
- ItemRow(
- title = row.title,
- items = row.items,
- onClickItem = { index, item ->
- onClickItem.invoke(RowColumn(rowIndex, index), item)
- },
- onLongClickItem = { index, item ->
- onLongClickItem.invoke(RowColumn(rowIndex, index), item)
- },
- modifier =
- Modifier
- .fillMaxWidth()
- .focusGroup()
- .focusRequester(rowFocusRequesters[rowIndex])
- .animateItem(),
- cardContent = { index, item, cardModifier, onClick, onLongClick ->
- BannerCard(
- name = item?.data?.seriesName ?: item?.name,
- item = item,
- aspectRatio = AspectRatios.TALL,
- cornerText = item?.ui?.episdodeUnplayedCornerText,
- played = item?.data?.userData?.played ?: false,
- favorite = item?.favorite ?: false,
- playPercent =
- item?.data?.userData?.playedPercentage
- ?: 0.0,
- onClick = onClick,
- onLongClick = onLongClick,
modifier =
- cardModifier
- .onFocusChanged {
- if (it.isFocused) {
- position = RowColumn(rowIndex, index)
-// item?.let(onUpdateBackdrop)
- }
- if (it.isFocused && onFocusPosition != null) {
- val nonEmptyRowBefore =
- homeRows
- .subList(0, rowIndex)
- .count {
- it is HomeRowLoadingState.Success && it.items.isEmpty()
- }
- onFocusPosition.invoke(
- RowColumn(
- rowIndex - nonEmptyRowBefore,
- index,
- ),
- )
- }
- }.onKeyEvent {
- if (isPlayKeyUp(it) && item?.type?.playable == true) {
- Timber.v("Clicked play on ${item.id}")
- onClickPlay.invoke(position, item)
- return@onKeyEvent true
- }
- return@onKeyEvent false
- },
- interactionSource = null,
- cardHeight = Cards.height2x3,
+ Modifier
+ .fillMaxWidth()
+ .focusGroup()
+ .focusRequester(rowFocusRequesters[rowIndex])
+ .animateItem(),
+ horizontalPadding = viewOptions.spacing.dp,
+ cardContent = { index, item, cardModifier, onClick, onLongClick ->
+ HomePageCardContent(
+ index = index,
+ item = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ viewOptions = viewOptions,
+ modifier =
+ cardModifier
+ .onFocusChanged {
+ if (it.isFocused) {
+ onFocusPosition?.invoke(
+ RowColumn(rowIndex, index),
+ )
+ }
+ }.onKeyEvent {
+ if (isPlayKeyUp(it) && item?.type?.playable == true) {
+ Timber.v("Clicked play on ${item.id}")
+ onClickPlay.invoke(
+ position,
+ item,
+ )
+ return@onKeyEvent true
+ }
+ return@onKeyEvent false
+ },
+ )
+ },
)
- },
- )
+ } else if (showEmptyRows) {
+ FocusableItemRow(
+ title = r.title,
+ subtitle = stringResource(R.string.no_results),
+ modifier = Modifier.animateItem(),
+ )
+ }
+ }
}
}
}
@@ -408,7 +415,7 @@ fun HomePageHeader(
subtitle = if (isEpisode) dto?.name else null,
overview = dto?.overview,
overviewTwoLines = isEpisode,
- quickDetails = item?.ui?.quickDetails,
+ quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""),
timeRemaining = item?.timeRemainingOrRuntime,
modifier = modifier,
)
@@ -469,3 +476,94 @@ fun HomePageHeader(
}
}
}
+
+@Composable
+fun HomePageCardContent(
+ index: Int,
+ item: BaseItem?,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ viewOptions: HomeRowViewOptions,
+ modifier: Modifier,
+) {
+ when (item?.type) {
+ BaseItemKind.GENRE -> {
+ GenreCard(
+ genreId = item.id,
+ name = item.name,
+ imageUrl = item.imageUrlOverride,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier = modifier.height(viewOptions.heightDp.dp),
+ )
+ }
+
+ else -> {
+ val imageType =
+ remember(item, viewOptions) {
+ if (item?.type == BaseItemKind.EPISODE) {
+ viewOptions.episodeImageType.imageType
+ } else {
+ viewOptions.imageType.imageType
+ }
+ }
+ val ratio =
+ remember(item, viewOptions) {
+ if (item?.type == BaseItemKind.EPISODE) {
+ viewOptions.episodeAspectRatio.ratio
+ } else {
+ viewOptions.aspectRatio.ratio
+ }
+ }
+ val scale =
+ remember(item, viewOptions) {
+ if (item?.type == BaseItemKind.EPISODE) {
+ viewOptions.episodeContentScale.scale
+ } else {
+ viewOptions.contentScale.scale
+ }
+ }
+ if (viewOptions.showTitles) {
+ BannerCardWithTitle(
+ title = item?.title,
+ subtitle = item?.subtitle,
+ item = item,
+ aspectRatio = ratio,
+ imageType = imageType,
+ imageContentScale = scale,
+ cornerText = item?.ui?.episodeUnplayedCornerText,
+ played = item?.data?.userData?.played ?: false,
+ favorite = item?.favorite ?: false,
+ playPercent =
+ item?.data?.userData?.playedPercentage
+ ?: 0.0,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier = modifier,
+ cardHeight = viewOptions.heightDp.dp,
+ useSeriesForPrimary = viewOptions.useSeries,
+ )
+ } else {
+ BannerCard(
+ name = item?.data?.seriesName ?: item?.name,
+ item = item,
+ aspectRatio = ratio,
+ imageType = imageType,
+ imageContentScale = scale,
+ cornerText = item?.ui?.episodeUnplayedCornerText,
+ played = item?.data?.userData?.played ?: false,
+ favorite = item?.favorite ?: false,
+ playPercent =
+ item?.data?.userData?.playedPercentage
+ ?: 0.0,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier = modifier,
+ interactionSource = null,
+ cardHeight = viewOptions.heightDp.dp,
+ useSeriesForPrimary = viewOptions.useSeries,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
index 1911e34d..53417863 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
@@ -1,36 +1,44 @@
package com.github.damontecres.wholphin.ui.main
import android.content.Context
-import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
-import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.data.model.HomeRowConfig
+import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
-import com.github.damontecres.wholphin.services.LatestNextUpService
+import com.github.damontecres.wholphin.services.HomePageResolvedSettings
+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.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
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.data.RowColumn
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
-import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
-import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
-import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
-import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.model.api.CollectionType
-import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@@ -40,113 +48,123 @@ class HomeViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
- val api: ApiClient,
val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
- val navDrawerItemRepository: NavDrawerItemRepository,
+ val mediaReportService: MediaReportService,
+ private val navDrawerService: NavDrawerService,
+ private val homeSettingsService: HomeSettingsService,
private val favoriteWatchManager: FavoriteWatchManager,
private val datePlayedService: DatePlayedService,
- private val latestNextUpService: LatestNextUpService,
private val backdropService: BackdropService,
private val userPreferencesService: UserPreferencesService,
+ private val mediaManagementService: MediaManagementService,
) : ViewModel() {
- val loadingState = MutableLiveData(LoadingState.Pending)
- val refreshState = MutableLiveData(LoadingState.Pending)
- val watchingRows = MutableLiveData>(listOf())
- val latestRows = MutableLiveData>(listOf())
-
- private lateinit var preferences: UserPreferences
+ private val _state = MutableStateFlow(HomeState.EMPTY)
+ val state: StateFlow = _state
init {
datePlayedService.invalidateAll()
- init()
+// init()
}
fun init() {
- viewModelScope.launch(
- Dispatchers.IO +
- LoadingExceptionHandler(
- loadingState,
- "Error loading home page",
- ),
- ) {
+ viewModelScope.launchIO {
Timber.d("init HomeViewModel")
- val reload = loadingState.value != LoadingState.Success
- if (reload) {
- loadingState.setValueOnMain(LoadingState.Loading)
- }
- refreshState.setValueOnMain(LoadingState.Loading)
- this@HomeViewModel.preferences = userPreferencesService.getCurrent()
- val prefs = preferences.appPreferences.homePagePreferences
- val limit = prefs.maxItemsPerRow
- if (reload) {
- backdropService.clearBackdrop()
- }
try {
+ val preferences = userPreferencesService.getCurrent()
+ val prefs = preferences.appPreferences.homePagePreferences
+
serverRepository.currentUserDto.value?.let { userDto ->
- val includedIds =
- navDrawerItemRepository
- .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
- .filter { it is ServerNavDrawerItem }
- .map { (it as ServerNavDrawerItem).itemId }
- val resume = latestNextUpService.getResume(userDto.id, limit, true)
- val nextUp =
- latestNextUpService.getNextUp(
- userDto.id,
- limit,
- prefs.enableRewatchingNextUp,
- false,
- )
- val watching =
- buildList {
- if (prefs.combineContinueNext) {
- val items = latestNextUpService.buildCombined(resume, nextUp)
- add(
- HomeRowLoadingState.Success(
- title = context.getString(R.string.continue_watching),
- items = items,
- ),
- )
- } else {
- if (resume.isNotEmpty()) {
- add(
- HomeRowLoadingState.Success(
- title = context.getString(R.string.continue_watching),
- items = resume,
- ),
- )
- }
- if (nextUp.isNotEmpty()) {
- add(
- HomeRowLoadingState.Success(
- title = context.getString(R.string.next_up),
- items = nextUp,
- ),
- )
+ val libraries =
+ navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
+ val settings =
+ homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
+ val state = state.value
+
+ // Refreshing if a load has already occurred and the rows haven't significantly changed
+ val refresh =
+ state.loadingState == LoadingState.Success && state.settings == settings
+ Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}")
+ _state.update { it.copy(settings = settings) }
+
+ val semaphore = Semaphore(4)
+
+ val watchingRowIndexes =
+ settings.rows
+ .mapIndexedNotNull { index, row ->
+ if (isWatchingRow(row.config)) index else null
+ }
+ val deferred =
+ settings.rows
+ // Load the watching rows first
+ .sortedByDescending { isWatchingRow(it.config) }
+ .map { row ->
+ viewModelScope.async(Dispatchers.IO) {
+ semaphore.withPermit {
+ Timber.v("Fetching row: %s", row)
+ try {
+ homeSettingsService.fetchDataForRow(
+ row = row.config,
+ scope = viewModelScope,
+ prefs = prefs,
+ userDto = userDto,
+ libraries = libraries,
+ limit = prefs.maxItemsPerRow,
+ isRefresh = refresh,
+ )
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error on row %s", row)
+ HomeRowLoadingState.Error(row.title, exception = ex)
+ }
+ }
}
}
- }
- val latest = latestNextUpService.getLatest(userDto, limit, includedIds)
- val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) }
-
- withContext(Dispatchers.Main) {
- this@HomeViewModel.watchingRows.value = watching
- if (reload) {
- this@HomeViewModel.latestRows.value = pendingLatest
+ if (refresh && state.homeRows.isNotEmpty() && watchingRowIndexes.isNotEmpty()) {
+ // Replace watching rows first
+ Timber.v("Refreshing rows: %s", watchingRowIndexes)
+ val rows =
+ deferred
+ .filterIndexed { index, _ -> index in watchingRowIndexes }
+ .awaitAll()
+ _state.update {
+ val newRows =
+ it.homeRows.toMutableList().apply {
+ rows.forEachIndexed { index, row ->
+ set(watchingRowIndexes[index], row)
+ }
+ }
+ it.copy(
+ loadingState = LoadingState.Success,
+ homeRows = newRows,
+ )
}
- loadingState.value = LoadingState.Success
}
- refreshState.setValueOnMain(LoadingState.Success)
- val loadedLatest = latestNextUpService.loadLatest(latest)
- this@HomeViewModel.latestRows.setValueOnMain(loadedLatest)
+ val rows =
+ deferred
+ .awaitAll()
+ .filter {
+ // Include only errors & non-empty successes
+ it is HomeRowLoadingState.Error ||
+ (it is HomeRowLoadingState.Success && it.items.isNotEmpty())
+ }
+ Timber.v("Got all rows")
+ _state.update {
+ it.copy(
+ loadingState = LoadingState.Success,
+ refreshState = LoadingState.Success,
+ homeRows = rows,
+ )
+ }
}
} catch (ex: Exception) {
- Timber.e(ex)
- if (!reload) {
- loadingState.setValueOnMain(LoadingState.Error(ex))
- } else {
+ Timber.e(ex, "Exception during home page loading")
+ if (state.value.loadingState == LoadingState.Success) {
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
+ } else {
+ _state.update {
+ it.copy(loadingState = LoadingState.Error(ex))
+ }
}
}
}
@@ -177,18 +195,59 @@ class HomeViewModel
backdropService.submit(item)
}
}
+
+ fun deleteItem(
+ position: RowColumn,
+ item: BaseItem,
+ ) {
+ deleteItem(context, mediaManagementService, item) {
+ viewModelScope.launchDefault {
+ val row = state.value.homeRows.getOrNull(position.row)
+ if (row is HomeRowLoadingState.Success) {
+ _state.update {
+ val newRow =
+ row.items.toMutableList().apply {
+ removeAt(position.column)
+ }
+ it.copy(
+ homeRows =
+ it.homeRows.toMutableList().apply {
+ set(position.row, row.copy(items = newRow))
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ fun canDelete(
+ item: BaseItem,
+ appPreferences: AppPreferences,
+ ): Boolean = mediaManagementService.canDelete(item, appPreferences)
}
-val supportedLatestCollectionTypes =
- setOf(
- CollectionType.MOVIES,
- CollectionType.TVSHOWS,
- CollectionType.HOMEVIDEOS,
- // Exclude Live TV because a recording folder view will be used instead
- null, // Recordings & mixed collection types
- )
+data class HomeState(
+ val loadingState: LoadingState,
+ val refreshState: LoadingState,
+ val homeRows: List,
+ val settings: HomePageResolvedSettings,
+) {
+ companion object {
+ val EMPTY =
+ HomeState(
+ LoadingState.Pending,
+ LoadingState.Pending,
+ listOf(),
+ HomePageResolvedSettings.EMPTY,
+ )
+ }
+}
-data class LatestData(
- val title: String,
- val request: GetLatestMediaRequest,
-)
+/**
+ * Whether a row is a "is watching" type
+ */
+private fun isWatchingRow(row: HomeRowConfig) =
+ row is HomeRowConfig.ContinueWatching ||
+ row is HomeRowConfig.NextUp ||
+ row is HomeRowConfig.ContinueWatchingCombined
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt
new file mode 100644
index 00000000..4236c03a
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt
@@ -0,0 +1,132 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.services.SuggestionsWorker
+import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import org.jellyfin.sdk.model.api.CollectionType
+
+@Composable
+fun HomeLibraryRowTypeList(
+ library: Library,
+ onClick: (LibraryRowType) -> Unit,
+ modifier: Modifier,
+ firstFocus: FocusRequester = remember { FocusRequester() },
+) {
+ val items = remember(library) { getSupportedRowTypes(library) }
+ LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
+ Column(modifier = modifier) {
+ TitleText(stringResource(R.string.add_row_for, library.name))
+ LazyColumn(
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .focusRestorer(firstFocus),
+ ) {
+ itemsIndexed(items) { index, rowType ->
+ ListItem(
+ selected = false,
+ headlineContent = {
+ Text(
+ text = stringResource(rowType.stringId),
+ )
+ },
+ onClick = { onClick.invoke(rowType) },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .ifElse(index == 0, Modifier.focusRequester(firstFocus)),
+ )
+ }
+ }
+ }
+}
+
+fun getSupportedRowTypes(library: Library): List {
+ val supportsSuggestions = SuggestionsWorker.getTypeForCollection(library.collectionType) != null
+ return when {
+ library.isRecordingFolder -> {
+ listOf(
+ LibraryRowType.RECENTLY_RECORDED,
+ LibraryRowType.GENRES,
+ )
+ }
+
+ library.collectionType == CollectionType.LIVETV -> {
+ listOf(
+ LibraryRowType.TV_CHANNELS,
+ LibraryRowType.TV_PROGRAMS,
+ )
+ }
+
+ supportsSuggestions -> {
+ listOf(
+ LibraryRowType.RECENTLY_ADDED,
+ LibraryRowType.RECENTLY_RELEASED,
+ LibraryRowType.SUGGESTIONS,
+ LibraryRowType.GENRES,
+ )
+ }
+
+ library.collectionType == CollectionType.BOXSETS -> {
+ listOf(
+ LibraryRowType.RECENTLY_ADDED,
+ LibraryRowType.RECENTLY_RELEASED,
+ LibraryRowType.GENRES,
+ LibraryRowType.COLLECTION,
+ )
+ }
+
+ library.collectionType == CollectionType.PLAYLISTS -> {
+ listOf(
+ LibraryRowType.RECENTLY_ADDED,
+ LibraryRowType.RECENTLY_RELEASED,
+ LibraryRowType.GENRES,
+ LibraryRowType.PLAYLIST,
+ )
+ }
+
+ else -> {
+ listOf(
+ LibraryRowType.RECENTLY_ADDED,
+ LibraryRowType.RECENTLY_RELEASED,
+ LibraryRowType.GENRES,
+ )
+ }
+ }
+}
+
+enum class LibraryRowType(
+ @param:StringRes val stringId: Int,
+) {
+ RECENTLY_ADDED(R.string.recently_added),
+ RECENTLY_RELEASED(R.string.recently_released),
+ SUGGESTIONS(R.string.suggestions),
+ GENRES(R.string.genres),
+ TV_CHANNELS(R.string.channels),
+ TV_PROGRAMS(R.string.live_tv),
+ RECENTLY_RECORDED(R.string.recently_recorded),
+ COLLECTION(R.string.collection),
+ PLAYLIST(R.string.playlist),
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt
new file mode 100644
index 00000000..eaf44d81
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt
@@ -0,0 +1,251 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
+import com.github.damontecres.wholphin.preferences.PrefContentScale
+import com.github.damontecres.wholphin.ui.AspectRatio
+import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import org.jellyfin.sdk.model.api.CollectionType
+
+data class HomeRowPresets(
+ val continueWatching: HomeRowViewOptions,
+ val movieLibrary: HomeRowViewOptions,
+ val tvLibrary: HomeRowViewOptions,
+ val videoLibrary: HomeRowViewOptions,
+ val photoLibrary: HomeRowViewOptions,
+ val playlist: HomeRowViewOptions,
+ val liveTv: HomeRowViewOptions,
+ val genreSize: Int,
+) {
+ fun getByCollectionType(collectionType: CollectionType): HomeRowViewOptions =
+ when (collectionType) {
+ CollectionType.MOVIES -> movieLibrary
+
+ CollectionType.TVSHOWS -> tvLibrary
+
+ CollectionType.MUSICVIDEOS -> videoLibrary
+
+ CollectionType.TRAILERS -> videoLibrary
+
+ CollectionType.HOMEVIDEOS -> videoLibrary
+
+ CollectionType.BOXSETS -> movieLibrary
+
+ CollectionType.PHOTOS -> photoLibrary
+
+ CollectionType.LIVETV -> liveTv
+
+ CollectionType.UNKNOWN,
+ CollectionType.MUSIC,
+ CollectionType.BOOKS,
+ CollectionType.PLAYLISTS,
+ CollectionType.FOLDERS,
+ -> HomeRowViewOptions()
+ }
+
+ companion object {
+ val WholphinDefault by lazy {
+ HomeRowPresets(
+ continueWatching = HomeRowViewOptions(),
+ movieLibrary = HomeRowViewOptions(),
+ tvLibrary = HomeRowViewOptions(),
+ videoLibrary =
+ HomeRowViewOptions(
+ aspectRatio = AspectRatio.WIDE,
+ ),
+ photoLibrary =
+ HomeRowViewOptions(
+ aspectRatio = AspectRatio.WIDE,
+ contentScale = PrefContentScale.CROP,
+ ),
+ playlist =
+ HomeRowViewOptions(
+ aspectRatio = AspectRatio.SQUARE,
+ contentScale = PrefContentScale.FIT,
+ ),
+ liveTv = HomeRowViewOptions.liveTvDefault,
+ genreSize = HomeRowViewOptions.genreDefault.heightDp,
+ )
+ }
+
+ val WholphinCompact by lazy {
+ val height = 148
+ val epHeight = 100
+ HomeRowPresets(
+ continueWatching =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ movieLibrary =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ tvLibrary =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ videoLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.WIDE,
+ ),
+ photoLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.WIDE,
+ contentScale = PrefContentScale.CROP,
+ ),
+ playlist =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.SQUARE,
+ contentScale = PrefContentScale.FIT,
+ ),
+ liveTv = HomeRowViewOptions.liveTvDefault,
+ genreSize = epHeight,
+ )
+ }
+
+ val SeriesThumbs by lazy {
+ val height = 148
+ val epHeight = 100
+ HomeRowPresets(
+ continueWatching =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ imageType = ViewOptionImageType.THUMB,
+ aspectRatio = AspectRatio.WIDE,
+ useSeries = true,
+ episodeImageType = ViewOptionImageType.THUMB,
+ episodeAspectRatio = AspectRatio.WIDE,
+ ),
+ movieLibrary =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ tvLibrary =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ videoLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.WIDE,
+ ),
+ photoLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.WIDE,
+ contentScale = PrefContentScale.CROP,
+ ),
+ playlist =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.SQUARE,
+ contentScale = PrefContentScale.FIT,
+ ),
+ liveTv = HomeRowViewOptions.liveTvDefault,
+ genreSize = epHeight,
+ )
+ }
+
+ val EpisodeThumbnails by lazy {
+ val height = 148
+ val epHeight = 100
+ HomeRowPresets(
+ continueWatching =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ imageType = ViewOptionImageType.THUMB,
+ aspectRatio = AspectRatio.WIDE,
+ showTitles = true,
+ useSeries = false,
+ episodeImageType = ViewOptionImageType.PRIMARY,
+ episodeAspectRatio = AspectRatio.WIDE,
+ ),
+ movieLibrary =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ tvLibrary =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ videoLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.WIDE,
+ ),
+ photoLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.WIDE,
+ contentScale = PrefContentScale.CROP,
+ ),
+ playlist =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.SQUARE,
+ contentScale = PrefContentScale.FIT,
+ ),
+ liveTv = HomeRowViewOptions.liveTvDefault,
+ genreSize = epHeight,
+ )
+ }
+ }
+}
+
+@Composable
+fun HomeRowPresetsContent(
+ onApply: (HomeRowPresets) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val presets =
+ listOf(
+ stringResource(R.string.display_preset_default) to HomeRowPresets.WholphinDefault,
+ stringResource(R.string.display_preset_compact) to HomeRowPresets.WholphinCompact,
+ stringResource(R.string.display_preset_series_thumb) to HomeRowPresets.SeriesThumbs,
+ stringResource(R.string.display_preset_episode_thumbnails) to HomeRowPresets.EpisodeThumbnails,
+ )
+
+ val focusRequesters = remember { List(presets.size) { FocusRequester() } }
+ LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() }
+ Column(modifier = modifier) {
+ TitleText(stringResource(R.string.display_presets))
+ LazyColumn(
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .focusRestorer(focusRequesters[0]),
+ ) {
+ itemsIndexed(presets) { index, (title, preset) ->
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = title,
+ onClick = {
+ onApply.invoke(preset)
+ },
+ modifier = Modifier.focusRequester(focusRequesters[index]),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt
new file mode 100644
index 00000000..7ac43548
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt
@@ -0,0 +1,307 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
+import com.github.damontecres.wholphin.preferences.AppChoicePreference
+import com.github.damontecres.wholphin.preferences.AppClickablePreference
+import com.github.damontecres.wholphin.preferences.AppPreference
+import com.github.damontecres.wholphin.preferences.AppSliderPreference
+import com.github.damontecres.wholphin.preferences.AppSwitchPreference
+import com.github.damontecres.wholphin.preferences.PrefContentScale
+import com.github.damontecres.wholphin.ui.AspectRatio
+import com.github.damontecres.wholphin.ui.Cards
+import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
+import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
+import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+
+@Composable
+fun HomeRowSettings(
+ title: String,
+ preferenceOptions: List>,
+ viewOptions: HomeRowViewOptions,
+ onViewOptionsChange: (HomeRowViewOptions) -> Unit,
+ onApplyApplyAll: () -> Unit,
+ modifier: Modifier = Modifier,
+ defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+) {
+ val firstFocus = remember { FocusRequester() }
+ LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
+ Column(modifier = modifier) {
+ TitleText(title)
+ LazyColumn {
+ preferenceOptions.forEachIndexed { groupIndex, prefGroup ->
+ if (preferenceOptions.size > 1) {
+ item {
+ TitleText(stringResource(prefGroup.title))
+ }
+ }
+ itemsIndexed(prefGroup.preferences) { index, 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,
+ onClickPreference = { pref ->
+ when (pref) {
+ Options.ViewOptionsReset -> {
+ onViewOptionsChange.invoke(defaultViewOptions)
+ }
+
+ Options.ViewOptionsApplyAll -> {
+ onApplyApplyAll.invoke()
+ }
+
+ Options.ViewOptionsUseThumb -> {
+ onViewOptionsChange.invoke(
+ viewOptions.copy(
+ heightDp = Cards.HEIGHT_EPISODE,
+ spacing = 20,
+ imageType = ViewOptionImageType.THUMB,
+ aspectRatio = AspectRatio.WIDE,
+ contentScale = PrefContentScale.FIT,
+ episodeImageType = ViewOptionImageType.THUMB,
+ episodeAspectRatio = AspectRatio.WIDE,
+ episodeContentScale = PrefContentScale.FIT,
+ ),
+ )
+ }
+ }
+ },
+ modifier =
+ Modifier
+ .ifElse(
+ groupIndex == 0 && index == 0,
+ Modifier.focusRequester(firstFocus),
+ ),
+ )
+ }
+ }
+ }
+ }
+}
+
+internal object Options {
+ val ViewOptionsCardHeight =
+ AppSliderPreference(
+ title = R.string.height,
+ defaultValue = Cards.HEIGHT_2X3_DP.toLong(),
+ min = 64L,
+ max = Cards.HEIGHT_2X3_DP + 64L,
+ interval = 4,
+ getter = { it.heightDp.toLong() },
+ setter = { prefs, value -> prefs.copy(heightDp = value.toInt()) },
+ )
+ val ViewOptionsSpacing =
+ AppSliderPreference(
+ title = R.string.spacing,
+ defaultValue = 16,
+ min = 0,
+ max = 32,
+ interval = 2,
+ getter = { it.spacing.toLong() },
+ setter = { prefs, value -> prefs.copy(spacing = value.toInt()) },
+ )
+
+ val ViewOptionsContentScale =
+ AppChoicePreference(
+ title = R.string.global_content_scale,
+ defaultValue = PrefContentScale.FIT,
+ displayValues = R.array.content_scale,
+ getter = { it.contentScale },
+ setter = { viewOptions, value -> viewOptions.copy(contentScale = value) },
+ indexToValue = { PrefContentScale.forNumber(it) },
+ valueToIndex = { it.number },
+ )
+
+ val ViewOptionsAspectRatio =
+ AppChoicePreference(
+ title = R.string.aspect_ratio,
+ defaultValue = AspectRatio.TALL,
+ displayValues = R.array.aspect_ratios,
+ getter = { it.aspectRatio },
+ setter = { viewOptions, value -> viewOptions.copy(aspectRatio = value) },
+ indexToValue = { AspectRatio.entries[it] },
+ valueToIndex = { it.ordinal },
+ )
+
+ val ViewOptionsShowTitles =
+ AppSwitchPreference(
+ title = R.string.show_titles,
+ defaultValue = true,
+ getter = { it.showTitles },
+ setter = { vo, value -> vo.copy(showTitles = value) },
+ )
+
+ val ViewOptionsUseSeries =
+ AppSwitchPreference(
+ title = R.string.use_series,
+ defaultValue = true,
+ getter = { it.useSeries },
+ setter = { vo, value -> vo.copy(useSeries = value) },
+ )
+
+ val ViewOptionsImageType =
+ AppChoicePreference(
+ title = R.string.image_type,
+ defaultValue = ViewOptionImageType.PRIMARY,
+ displayValues = R.array.image_types,
+ getter = { it.imageType },
+ setter = { viewOptions, value ->
+ val aspectRatio =
+ when (value) {
+ ViewOptionImageType.PRIMARY -> AspectRatio.TALL
+ ViewOptionImageType.THUMB -> AspectRatio.WIDE
+ }
+ viewOptions.copy(imageType = value, aspectRatio = aspectRatio)
+ },
+ indexToValue = { ViewOptionImageType.entries[it] },
+ valueToIndex = { it.ordinal },
+ )
+
+ val ViewOptionsApplyAll =
+ AppClickablePreference(
+ title = R.string.apply_all_rows,
+ )
+
+ val ViewOptionsReset =
+ AppClickablePreference(
+ title = R.string.reset,
+ )
+
+ val ViewOptionsUseThumb =
+ AppClickablePreference(
+ title = R.string.use_thumb_images,
+ )
+
+ val ViewOptionsEpisodeContentScale =
+ AppChoicePreference(
+ title = R.string.global_content_scale,
+ defaultValue = PrefContentScale.FIT,
+ displayValues = R.array.content_scale,
+ getter = { it.episodeContentScale },
+ setter = { viewOptions, value -> viewOptions.copy(episodeContentScale = value) },
+ indexToValue = { PrefContentScale.forNumber(it) },
+ valueToIndex = { it.number },
+ )
+
+ val ViewOptionsEpisodeAspectRatio =
+ AppChoicePreference(
+ title = R.string.aspect_ratio,
+ defaultValue = AspectRatio.TALL,
+ displayValues = R.array.aspect_ratios,
+ getter = { it.episodeAspectRatio },
+ setter = { viewOptions, value -> viewOptions.copy(episodeAspectRatio = value) },
+ indexToValue = { AspectRatio.entries[it] },
+ valueToIndex = { it.ordinal },
+ )
+
+ val ViewOptionsEpisodeImageType =
+ AppChoicePreference(
+ title = R.string.image_type,
+ defaultValue = ViewOptionImageType.PRIMARY,
+ displayValues = R.array.image_types,
+ getter = { it.episodeImageType },
+ setter = { viewOptions, value ->
+ val aspectRatio =
+ when (value) {
+ ViewOptionImageType.PRIMARY -> AspectRatio.TALL
+ ViewOptionImageType.THUMB -> AspectRatio.WIDE
+ }
+ viewOptions.copy(episodeImageType = value, episodeAspectRatio = aspectRatio)
+ },
+ indexToValue = { ViewOptionImageType.entries[it] },
+ valueToIndex = { it.ordinal },
+ )
+
+ val OPTIONS =
+ listOf(
+ PreferenceGroup(
+ title = R.string.general,
+ preferences =
+ listOf(
+ ViewOptionsCardHeight,
+ ViewOptionsSpacing,
+ ViewOptionsShowTitles,
+ ViewOptionsImageType,
+ ViewOptionsAspectRatio,
+ ViewOptionsContentScale,
+ ViewOptionsUseSeries,
+ ),
+ ),
+ PreferenceGroup(
+ title = R.string.more,
+ preferences =
+ listOf(
+// ViewOptionsApplyAll,
+ ViewOptionsUseThumb,
+ ViewOptionsReset,
+ ),
+ ),
+ )
+
+ val OPTIONS_EPISODES =
+ listOf(
+ PreferenceGroup(
+ title = R.string.general,
+ preferences =
+ listOf(
+ ViewOptionsCardHeight,
+ ViewOptionsSpacing,
+ ViewOptionsShowTitles,
+ ViewOptionsImageType,
+ ViewOptionsAspectRatio,
+ ViewOptionsContentScale,
+ ),
+ ),
+ PreferenceGroup(
+ title = R.string.for_episodes,
+ preferences =
+ listOf(
+ ViewOptionsUseSeries,
+ ViewOptionsEpisodeImageType,
+ ViewOptionsEpisodeAspectRatio,
+ ViewOptionsEpisodeContentScale,
+ ),
+ ),
+ PreferenceGroup(
+ title = R.string.more,
+ preferences =
+ listOf(
+ ViewOptionsUseThumb,
+ ViewOptionsReset,
+ ),
+ ),
+ )
+
+ val GENRE_OPTIONS =
+ listOf(
+ PreferenceGroup(
+ title = R.string.general,
+ preferences =
+ listOf(
+ ViewOptionsCardHeight,
+ ViewOptionsSpacing,
+ ViewOptionsReset,
+ ),
+ ),
+ )
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt
new file mode 100644
index 00000000..97249aa4
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt
@@ -0,0 +1,110 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.ui.ifElse
+
+@Composable
+fun HomeSettingsAddRow(
+ libraries: List,
+ showDiscover: Boolean,
+ onClick: (Library) -> Unit,
+ onClickMeta: (MetaRowType) -> Unit,
+ modifier: Modifier,
+ firstFocus: FocusRequester = remember { FocusRequester() },
+) {
+// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
+ Column(modifier = modifier) {
+ TitleText(stringResource(R.string.add_row))
+ LazyColumn(
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .focusRestorer(firstFocus),
+ ) {
+ itemsIndexed(
+ listOf(
+ MetaRowType.CONTINUE_WATCHING,
+ MetaRowType.NEXT_UP,
+ MetaRowType.COMBINED_CONTINUE_WATCHING,
+ ),
+ ) { index, type ->
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(type.stringId),
+ onClick = { onClickMeta.invoke(type) },
+ modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
+ )
+ }
+ item {
+ TitleText(stringResource(R.string.library))
+ HorizontalDivider()
+ }
+ itemsIndexed(libraries) { index, library ->
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = library.name,
+ onClick = { onClick.invoke(library) },
+ modifier = Modifier, // .ifElse(index == 0, Modifier.focusRequester(firstFocus)),
+ )
+ }
+ item {
+ TitleText(stringResource(R.string.more))
+ HorizontalDivider()
+ }
+ itemsIndexed(
+ listOf(
+ MetaRowType.FAVORITES,
+ MetaRowType.COLLECTION,
+ MetaRowType.PLAYLIST,
+ ),
+ ) { index, type ->
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(type.stringId),
+ onClick = { onClickMeta.invoke(type) },
+ modifier = Modifier,
+ )
+ }
+ if (showDiscover) {
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(MetaRowType.DISCOVER.stringId),
+ onClick = { onClickMeta.invoke(MetaRowType.DISCOVER) },
+ modifier = Modifier,
+ )
+ }
+ }
+ }
+ }
+}
+
+enum class MetaRowType(
+ @param:StringRes val stringId: Int,
+) {
+ CONTINUE_WATCHING(R.string.continue_watching),
+ NEXT_UP(R.string.next_up),
+ COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next),
+ FAVORITES(R.string.favorites),
+ DISCOVER(R.string.discover),
+ COLLECTION(R.string.collection),
+ PLAYLIST(R.string.playlist),
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt
new file mode 100644
index 00000000..ad433c23
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt
@@ -0,0 +1,38 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.navigation3.runtime.NavKey
+import kotlinx.serialization.Serializable
+
+/**
+ * Tracking the pages for selecting and configuring rows
+ */
+@Serializable
+sealed interface HomeSettingsDestination : NavKey {
+ @Serializable
+ data object RowList : HomeSettingsDestination
+
+ @Serializable
+ data object AddRow : HomeSettingsDestination
+
+ @Serializable
+ data class ChooseRowType(
+ val library: Library,
+ ) : HomeSettingsDestination
+
+ @Serializable
+ data class RowSettings(
+ val rowId: Int,
+ ) : HomeSettingsDestination
+
+ @Serializable
+ data object ChooseFavorite : HomeSettingsDestination
+
+ @Serializable
+ data object ChooseDiscover : HomeSettingsDestination
+
+ @Serializable
+ data object GlobalSettings : HomeSettingsDestination
+
+ @Serializable
+ data object Presets : HomeSettingsDestination
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt
new file mode 100644
index 00000000..3bac590a
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt
@@ -0,0 +1,64 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import org.jellyfin.sdk.model.api.BaseItemKind
+
+@Composable
+fun HomeSettingsFavoriteList(
+ onClick: (BaseItemKind) -> Unit,
+ modifier: Modifier = Modifier,
+ firstFocus: FocusRequester = remember { FocusRequester() },
+) {
+ LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
+ Column(modifier = modifier) {
+ TitleText(
+ stringResource(R.string.add_row_for, stringResource(R.string.favorites)),
+ )
+ LazyColumn(
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .focusRestorer(firstFocus),
+ ) {
+ itemsIndexed(favoriteOptionsList) { index, type ->
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(favoriteOptions[type]!!),
+ onClick = { onClick.invoke(type) },
+ modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
+ )
+ }
+ }
+ }
+}
+
+val favoriteOptions by lazy {
+ mapOf(
+ BaseItemKind.MOVIE to R.string.movies,
+ BaseItemKind.SERIES to R.string.tv_shows,
+ BaseItemKind.EPISODE to R.string.episodes,
+ BaseItemKind.VIDEO to R.string.videos,
+ BaseItemKind.PLAYLIST to R.string.playlists,
+ BaseItemKind.PERSON to R.string.people,
+ )
+}
+val favoriteOptionsList by lazy { favoriteOptions.keys.toList() }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt
new file mode 100644
index 00000000..e4b61881
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt
@@ -0,0 +1,184 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.preferences.AppPreference
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.ui.FontAwesome
+import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+
+@Composable
+fun HomeSettingsGlobal(
+ preferences: AppPreferences,
+ onPreferenceChange: (AppPreferences) -> Unit,
+ onClickResize: (Int) -> Unit,
+ onClickSave: () -> Unit,
+ onClickLoad: () -> Unit,
+ onClickLoadWeb: () -> Unit,
+ onClickReset: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val firstFocus: FocusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
+ Column(modifier = modifier) {
+ Text(
+ text = stringResource(R.string.settings),
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ HorizontalDivider()
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .focusRestorer(firstFocus),
+ ) {
+ item {
+ ComposablePreference(
+ preference = AppPreference.HomePageItems,
+ value = AppPreference.HomePageItems.getter.invoke(preferences),
+ onValueChange = {
+ val newPrefs = AppPreference.HomePageItems.setter.invoke(preferences, it)
+ onPreferenceChange.invoke(newPrefs)
+ },
+ onNavigate = {},
+ modifier = Modifier.focusRequester(firstFocus),
+ )
+ }
+ item {
+ ComposablePreference(
+ preference = AppPreference.RewatchNextUp,
+ value = AppPreference.RewatchNextUp.getter.invoke(preferences),
+ onValueChange = {
+ val newPrefs = AppPreference.RewatchNextUp.setter.invoke(preferences, it)
+ onPreferenceChange.invoke(newPrefs)
+ },
+ onNavigate = {},
+ modifier = Modifier,
+ )
+ }
+ item {
+ ComposablePreference(
+ preference = AppPreference.MaxDaysNextUp,
+ value = AppPreference.MaxDaysNextUp.getter.invoke(preferences),
+ onValueChange = {
+ val newPrefs = AppPreference.MaxDaysNextUp.setter.invoke(preferences, it)
+ onPreferenceChange.invoke(newPrefs)
+ },
+ onNavigate = {},
+ modifier = Modifier,
+ )
+ }
+ item { HorizontalDivider() }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.increase_all_cards_size),
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowUp,
+ contentDescription = null,
+ )
+ },
+ onClick = { onClickResize.invoke(1) },
+ modifier = Modifier,
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.decrease_all_cards_size),
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ )
+ },
+ onClick = { onClickResize.invoke(-1) },
+ modifier = Modifier,
+ )
+ }
+ item { HorizontalDivider() }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.save_to_server),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_cloud_arrow_up),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickSave,
+ modifier = Modifier,
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.load_from_server),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_cloud_arrow_down),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickLoad,
+ modifier = Modifier,
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.load_from_web_client),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_download),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickLoadWeb,
+ modifier = Modifier,
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.reset),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_arrows_rotate),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickReset,
+ modifier = Modifier,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt
new file mode 100644
index 00000000..4f265dbb
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt
@@ -0,0 +1,59 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.ListItemBorder
+import androidx.tv.material3.ListItemColors
+import androidx.tv.material3.ListItemDefaults
+import androidx.tv.material3.ListItemGlow
+import androidx.tv.material3.ListItemScale
+import androidx.tv.material3.ListItemShape
+import com.github.damontecres.wholphin.ui.preferences.PreferenceTitle
+
+@Composable
+@NonRestartableComposable
+fun HomeSettingsListItem(
+ selected: Boolean,
+ onClick: () -> Unit,
+ headlineText: String,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ onLongClick: (() -> Unit)? = null,
+ overlineContent: (@Composable () -> Unit)? = null,
+ supportingContent: (@Composable () -> Unit)? = null,
+ leadingContent: (@Composable BoxScope.() -> Unit)? = null,
+ trailingContent: (@Composable () -> Unit)? = null,
+ tonalElevation: Dp = 3.dp,
+ shape: ListItemShape = ListItemDefaults.shape(),
+ colors: ListItemColors = ListItemDefaults.colors(),
+ scale: ListItemScale = ListItemDefaults.scale(),
+ border: ListItemBorder = ListItemDefaults.border(),
+ glow: ListItemGlow = ListItemDefaults.glow(),
+ interactionSource: MutableInteractionSource? = null,
+) = ListItem(
+ selected = selected,
+ onClick = onClick,
+ headlineContent = {
+ PreferenceTitle(headlineText)
+ },
+ modifier = modifier,
+ enabled = enabled,
+ onLongClick = onLongClick,
+ overlineContent = overlineContent,
+ supportingContent = supportingContent,
+ leadingContent = leadingContent,
+ trailingContent = trailingContent,
+ tonalElevation = tonalElevation,
+ shape = shape,
+ colors = colors,
+ scale = scale,
+ border = border,
+ glow = glow,
+ interactionSource = interactionSource,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt
new file mode 100644
index 00000000..fb025c02
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt
@@ -0,0 +1,345 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.rememberLazyListState
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
+import androidx.navigation3.ui.NavDisplay
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.surfaceColorAtElevation
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.HomeRowConfig
+import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.ui.components.ConfirmDialog
+import com.github.damontecres.wholphin.ui.data.RowColumn
+import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog
+import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.main.HomePageContent
+import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType
+import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.RowSettings
+import com.github.damontecres.wholphin.ui.rememberPosition
+import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.HomeRowLoadingState
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import org.jellyfin.sdk.model.api.BaseItemKind
+import timber.log.Timber
+
+val settingsWidth = 360.dp
+
+@Composable
+fun HomeSettingsPage(
+ modifier: Modifier,
+ viewModel: HomeSettingsViewModel = hiltViewModel(),
+) {
+ val scope = rememberCoroutineScope()
+ val listState = rememberLazyListState()
+ val backStack = rememberNavBackStack(HomeSettingsDestination.RowList)
+ var showConfirmDialog by remember { mutableStateOf(null) }
+ var searchForDialog by remember { mutableStateOf(null) }
+
+ val state by viewModel.state.collectAsState()
+ var position by rememberPosition(0, 0)
+ // TODO discover rows
+ val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false)
+
+ // Adds a row, waits until its done loading, then scrolls to the new row
+ fun addRow(
+ scrollToBottom: Boolean = true,
+ func: () -> Job,
+ ) {
+ scope.launch(ExceptionHandler(autoToast = true)) {
+ while (backStack.size > 1) {
+ backStack.removeAt(backStack.lastIndex)
+ }
+ func.invoke().join()
+ if (scrollToBottom) {
+ listState.animateScrollToItem(state.rows.lastIndex)
+ }
+ }
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = modifier,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .width(settingsWidth)
+ .fillMaxHeight()
+ .background(color = MaterialTheme.colorScheme.surface),
+ ) {
+ NavDisplay(
+ backStack = backStack,
+// onBack = { navigationManager.goBack() },
+ entryDecorators =
+ listOf(
+ rememberSaveableStateHolderNavEntryDecorator(),
+ rememberViewModelStoreNavEntryDecorator(),
+ ),
+ modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
+ entryProvider = { key ->
+ val dest = key as HomeSettingsDestination
+ NavEntry(dest, contentKey = key.toString()) {
+ val destModifier =
+ Modifier
+ .fillMaxSize()
+ .padding(8.dp)
+ when (dest) {
+ HomeSettingsDestination.RowList -> {
+ HomeSettingsRowList(
+ state = state,
+ onClickAdd = { backStack.add(HomeSettingsDestination.AddRow) },
+ onClickSettings = { backStack.add(HomeSettingsDestination.GlobalSettings) },
+ onClickPresets = { backStack.add(HomeSettingsDestination.Presets) },
+ onClickMove = viewModel::moveRow,
+ onClickDelete = viewModel::deleteRow,
+ onClick = { index, row ->
+ backStack.add(RowSettings(row.id))
+ scope.launch(ExceptionHandler()) {
+ Timber.v("Scroll to $index")
+ listState.animateScrollToItem(index)
+ // Update backdrop to first item in the row
+ (state.rowData.getOrNull(index) as? HomeRowLoadingState.Success)
+ ?.items
+ ?.firstOrNull()
+ ?.let {
+ viewModel.updateBackdrop(it)
+ }
+ position = RowColumn(index, 0)
+ }
+ },
+ modifier = destModifier,
+ )
+ }
+
+ is HomeSettingsDestination.AddRow -> {
+ HomeSettingsAddRow(
+ libraries = state.libraries,
+ showDiscover = discoverEnabled,
+ onClick = { backStack.add(ChooseRowType(it)) },
+ onClickMeta = {
+ when (it) {
+ MetaRowType.CONTINUE_WATCHING,
+ MetaRowType.NEXT_UP,
+ MetaRowType.COMBINED_CONTINUE_WATCHING,
+ -> {
+ addRow { viewModel.addRow(it) }
+ }
+
+ MetaRowType.FAVORITES -> {
+ backStack.add(HomeSettingsDestination.ChooseFavorite)
+ }
+
+ MetaRowType.DISCOVER -> {
+ backStack.add(HomeSettingsDestination.ChooseDiscover)
+ }
+
+ MetaRowType.COLLECTION -> {
+ searchForDialog = BaseItemKind.BOX_SET
+ }
+
+ MetaRowType.PLAYLIST -> {
+ searchForDialog = BaseItemKind.PLAYLIST
+ }
+ }
+ },
+ modifier = destModifier,
+ )
+ }
+
+ is ChooseRowType -> {
+ HomeLibraryRowTypeList(
+ library = dest.library,
+ onClick = { type ->
+ when (type) {
+ LibraryRowType.COLLECTION -> {
+ searchForDialog = BaseItemKind.BOX_SET
+ }
+
+ LibraryRowType.PLAYLIST -> {
+ searchForDialog = BaseItemKind.PLAYLIST
+ }
+
+ else -> {
+ addRow { viewModel.addRow(dest.library, type) }
+ }
+ }
+ },
+ modifier = destModifier,
+ )
+ }
+
+ is RowSettings -> {
+ val row =
+ state.rows
+ .first { it.id == dest.rowId }
+ val preferenceOptions =
+ remember(row.config) {
+ when (row.config) {
+ is HomeRowConfig.ContinueWatching,
+ is HomeRowConfig.ContinueWatchingCombined,
+ -> Options.OPTIONS_EPISODES
+
+ is HomeRowConfig.Genres -> Options.GENRE_OPTIONS
+
+ else -> Options.OPTIONS
+ }
+ }
+ val defaultViewOptions =
+ remember(row.config) {
+ when (row.config) {
+ is HomeRowConfig.Genres -> HomeRowViewOptions.genreDefault
+ else -> HomeRowViewOptions()
+ }
+ }
+ HomeRowSettings(
+ title = row.title,
+ preferenceOptions = preferenceOptions,
+ viewOptions = row.config.viewOptions,
+ defaultViewOptions = defaultViewOptions,
+ onViewOptionsChange = {
+ viewModel.updateViewOptions(dest.rowId, it)
+ },
+ onApplyApplyAll = {
+ viewModel.updateViewOptionsForAll(row.config.viewOptions)
+ },
+ modifier = destModifier,
+ )
+ }
+
+ HomeSettingsDestination.ChooseDiscover -> {
+ TODO()
+ }
+
+ HomeSettingsDestination.ChooseFavorite -> {
+ HomeSettingsFavoriteList(
+ onClick = { type ->
+ addRow { viewModel.addFavoriteRow(type) }
+ },
+ modifier = destModifier,
+ )
+ }
+
+ HomeSettingsDestination.GlobalSettings -> {
+ val preferences by
+ viewModel.preferencesDataStore.data.collectAsState(
+ AppPreferences.getDefaultInstance(),
+ )
+
+ HomeSettingsGlobal(
+ preferences = preferences,
+ onPreferenceChange = { newPrefs ->
+ scope.launchIO {
+ viewModel.preferencesDataStore.updateData { newPrefs }
+ }
+ },
+ onClickResize = { viewModel.resizeCards(it) },
+ onClickSave = {
+ showConfirmDialog =
+ ShowConfirm(R.string.overwrite_server_settings) {
+ viewModel.saveToRemote()
+ }
+ },
+ onClickLoad = {
+ showConfirmDialog =
+ ShowConfirm(R.string.overwrite_local_settings) {
+ viewModel.loadFromRemote()
+ }
+ },
+ onClickLoadWeb = {
+ showConfirmDialog =
+ ShowConfirm(R.string.overwrite_local_settings) {
+ viewModel.loadFromRemoteWeb()
+ }
+ },
+ onClickReset = {
+ showConfirmDialog =
+ ShowConfirm(R.string.overwrite_local_settings) {
+ addRow(false) { viewModel.resetToDefault() }
+ }
+ },
+ modifier = destModifier,
+ )
+ }
+
+ HomeSettingsDestination.Presets -> {
+ HomeRowPresetsContent(
+ onApply = viewModel::applyPreset,
+ modifier = destModifier,
+ )
+ }
+ }
+ }
+ },
+ )
+ }
+ HomePageContent(
+ loadingState = state.loading,
+ homeRows = state.rowData,
+ position = position,
+ onFocusPosition = { position = it },
+ onClickItem = { _, _ -> },
+ onLongClickItem = { _, _ -> },
+ onClickPlay = { _, _ -> },
+ showClock = false,
+ onUpdateBackdrop = viewModel::updateBackdrop,
+ listState = listState,
+ takeFocus = false,
+ showEmptyRows = true,
+ modifier =
+ Modifier
+ .fillMaxHeight()
+ .weight(1f),
+ )
+ }
+ showConfirmDialog?.let { (body, onConfirm) ->
+ ConfirmDialog(
+ title = stringResource(R.string.confirm),
+ body = stringResource(body),
+ onCancel = { showConfirmDialog = null },
+ onConfirm = {
+ onConfirm.invoke()
+ showConfirmDialog = null
+ },
+ )
+ }
+ searchForDialog?.let { searchType ->
+ SearchForDialog(
+ onDismissRequest = { searchForDialog = null },
+ searchType = searchType,
+ onClick = {
+ searchForDialog = null
+ addRow { viewModel.addRow(searchType, it) }
+ },
+ )
+ }
+}
+
+data class ShowConfirm(
+ @param:StringRes val body: Int,
+ val onConfirm: () -> Unit,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt
new file mode 100644
index 00000000..9eadc615
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt
@@ -0,0 +1,269 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
+import com.github.damontecres.wholphin.ui.FontAwesome
+import com.github.damontecres.wholphin.ui.components.Button
+import com.github.damontecres.wholphin.ui.rememberInt
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import kotlinx.coroutines.launch
+
+enum class MoveDirection {
+ UP,
+ DOWN,
+}
+
+@Composable
+fun HomeSettingsRowList(
+ state: HomePageSettingsState,
+ onClick: (Int, HomeRowConfigDisplay) -> Unit,
+ onClickAdd: () -> Unit,
+ onClickSettings: () -> Unit,
+ onClickPresets: () -> Unit,
+ onClickMove: (MoveDirection, Int) -> Unit,
+ onClickDelete: (Int) -> Unit,
+ modifier: Modifier,
+) {
+ val focusManager = LocalFocusManager.current
+ val scope = rememberCoroutineScope()
+ val listState = rememberLazyListState()
+
+ val itemsBeforeRows = 4
+ val focusRequesters =
+ remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } }
+
+ var position by rememberInt(0)
+
+ LaunchedEffect(Unit) {
+ focusRequesters.getOrNull(position)?.tryRequestFocus()
+ }
+ Column(modifier = modifier) {
+ TitleText(stringResource(R.string.customize_home))
+ LazyColumn(
+ state = listState,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .focusRestorer(focusRequesters[0]),
+ ) {
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.add_row),
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ )
+ },
+ onClick = {
+ position = 0
+ onClickAdd.invoke()
+ },
+ modifier = Modifier.focusRequester(focusRequesters[0]),
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.settings),
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = null,
+ )
+ },
+ onClick = {
+ position = 1
+ onClickSettings.invoke()
+ },
+ modifier = Modifier.focusRequester(focusRequesters[1]),
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.display_presets),
+ supportingContent = {
+ Text(
+ text = stringResource(R.string.display_presets_description),
+ )
+ },
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_sliders),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = {
+ position = 2
+ onClickPresets.invoke()
+ },
+ modifier = Modifier.focusRequester(focusRequesters[2]),
+ )
+ }
+ item {
+ TitleText(stringResource(R.string.home_rows) + " (${state.rows.size})")
+ HorizontalDivider()
+ }
+ itemsIndexed(state.rows, key = { _, row -> row.id }) { index, row ->
+ HomeRowConfigContent(
+ config = row,
+ moveUpAllowed = index > 0,
+ moveDownAllowed = index != state.rows.lastIndex,
+ deleteAllowed = state.rows.size > 1,
+ onClickMove = {
+ onClickMove.invoke(it, index)
+ scope.launch {
+ val scrollIndex =
+ itemsBeforeRows + if (it == MoveDirection.UP) index - 1 else index + 1
+ if (scrollIndex < listState.firstVisibleItemIndex ||
+ scrollIndex > listState.layoutInfo.visibleItemsInfo.lastIndex
+ ) {
+ listState.animateScrollToItem(scrollIndex)
+ }
+ }
+ },
+ onClickDelete = {
+ if (index != state.rows.lastIndex) {
+ focusManager.moveFocus(FocusDirection.Down)
+ } else {
+ focusManager.moveFocus(FocusDirection.Up)
+ }
+ onClickDelete.invoke(index)
+ },
+ onClick = {
+ position = itemsBeforeRows + index
+ onClick.invoke(index, row)
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .animateItem()
+ .focusRequester(focusRequesters[itemsBeforeRows + index]),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun HomeRowConfigContent(
+ config: HomeRowConfigDisplay,
+ moveUpAllowed: Boolean,
+ moveDownAllowed: Boolean,
+ deleteAllowed: Boolean,
+ onClick: () -> Unit,
+ onClickMove: (MoveDirection) -> Unit,
+ onClickDelete: () -> Unit,
+ modifier: Modifier,
+) {
+ Box(
+ modifier = modifier,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .heightIn(min = 40.dp, max = 88.dp),
+ ) {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = config.title,
+ onClick = onClick,
+ modifier = Modifier.weight(1f),
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.wrapContentWidth(),
+ ) {
+ Button(
+ onClick = { onClickMove.invoke(MoveDirection.UP) },
+ enabled = moveUpAllowed,
+ ) {
+ Text(
+ text = stringResource(R.string.fa_caret_up),
+ fontFamily = FontAwesome,
+ )
+ }
+ Button(
+ onClick = { onClickMove.invoke(MoveDirection.DOWN) },
+ enabled = moveDownAllowed,
+ ) {
+ Text(
+ text = stringResource(R.string.fa_caret_down),
+ fontFamily = FontAwesome,
+ )
+ }
+ Button(
+ onClick = onClickDelete,
+ enabled = deleteAllowed,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "delete",
+ modifier = Modifier,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+@NonRestartableComposable
+fun TitleText(
+ title: String,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Start,
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp, bottom = 4.dp),
+ )
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt
new file mode 100644
index 00000000..67b57e28
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt
@@ -0,0 +1,754 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import android.content.Context
+import android.widget.Toast
+import androidx.compose.runtime.Immutable
+import androidx.datastore.core.DataStore
+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.HomePageSettings
+import com.github.damontecres.wholphin.data.model.HomeRowConfig
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatching
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatchingCombined
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.Genres
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.NextUp
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyAdded
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyReleased
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.Suggestions
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvChannels
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms
+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.preferences.AppPreferences
+import com.github.damontecres.wholphin.services.BackdropService
+import com.github.damontecres.wholphin.services.HomePageResolvedSettings
+import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
+import com.github.damontecres.wholphin.services.HomeSettingsService
+import com.github.damontecres.wholphin.services.NavDrawerService
+import com.github.damontecres.wholphin.services.SeerrServerRepository
+import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
+import com.github.damontecres.wholphin.services.UserPreferencesService
+import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
+import com.github.damontecres.wholphin.services.tvAccess
+import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.showToast
+import com.github.damontecres.wholphin.util.HomeRowLoadingState
+import com.github.damontecres.wholphin.util.LoadingState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import kotlinx.serialization.Serializable
+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.serializer.UUIDSerializer
+import timber.log.Timber
+import java.util.UUID
+import javax.inject.Inject
+import kotlin.properties.Delegates
+
+@HiltViewModel
+class HomeSettingsViewModel
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
+ private val homeSettingsService: HomeSettingsService,
+ private val serverRepository: ServerRepository,
+ private val userPreferencesService: UserPreferencesService,
+ private val navDrawerService: NavDrawerService,
+ private val backdropService: BackdropService,
+ private val seerrServerRepository: SeerrServerRepository,
+ val preferencesDataStore: DataStore,
+ @param:IoCoroutineScope private val ioScope: CoroutineScope,
+ ) : ViewModel() {
+ private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
+ val state: StateFlow = _state
+
+ private var idCounter by Delegates.notNull()
+
+ val discoverEnabled = seerrServerRepository.active
+
+ private var originalLocalSettings: HomePageSettings? = null
+ private var originalRemoteSettings: HomePageSettings? = null
+
+ init {
+ addCloseable { saveToLocal() }
+ viewModelScope.launchIO {
+ val userDto = serverRepository.currentUserDto.value ?: return@launchIO
+ val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
+ val currentSettings =
+ homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
+ originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id)
+ originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id)
+ Timber.v("currentSettings=%s", currentSettings)
+ idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
+ _state.update {
+ it.copy(
+ libraries = libraries,
+ rows = currentSettings.rows,
+ )
+ }
+ fetchRowData()
+ }
+ }
+
+ fun updateBackdrop(item: BaseItem) {
+ viewModelScope.launchIO {
+ backdropService.submit(item)
+ }
+ }
+
+ private suspend fun fetchRowData() {
+ val limit = 8
+ val semaphore = Semaphore(4)
+ val rows =
+ serverRepository.currentUserDto.value?.let { userDto ->
+ val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences
+ state.value
+ .let { state ->
+ state.rows
+ .map { it.config }
+ .map { row ->
+ viewModelScope.async(Dispatchers.IO) {
+ semaphore.withPermit {
+ homeSettingsService.fetchDataForRow(
+ row = row,
+ scope = viewModelScope,
+ prefs = prefs,
+ userDto = userDto,
+ libraries = state.libraries,
+ limit = limit,
+ isRefresh = false,
+ )
+ }
+ }
+ }
+ }.awaitAll()
+ }
+ rows?.let { rows ->
+ rows
+ .firstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
+ ?.let {
+ it as HomeRowLoadingState.Success
+ it.items.firstOrNull()?.let {
+ Timber.v("Updating backdrop")
+ updateBackdrop(it)
+ }
+ }
+ updateState {
+ it.copy(loading = LoadingState.Success, rowData = rows)
+ }
+ }
+ }
+
+ private fun List.move(
+ direction: MoveDirection,
+ index: Int,
+ ): List =
+ toMutableList().apply {
+ if (direction == MoveDirection.DOWN) {
+ val down = this[index]
+ val up = this[index + 1]
+ set(index, up)
+ set(index + 1, down)
+ } else {
+ val up = this[index]
+ val down = this[index - 1]
+ set(index - 1, up)
+ set(index, down)
+ }
+ }
+
+ fun moveRow(
+ direction: MoveDirection,
+ index: Int,
+ ) {
+ viewModelScope.launchIO {
+ updateState {
+ val rows = it.rows.move(direction, index)
+ val rowData = it.rowData.move(direction, index)
+ it.copy(
+ rows = rows,
+ rowData = rowData,
+ )
+ }
+ }
+// viewModelScope.launchIO { fetchRowData() }
+ }
+
+ fun deleteRow(index: Int) {
+ viewModelScope.launchIO {
+ updateState {
+ val rows = it.rows.toMutableList().apply { removeAt(index) }
+ val rowData = it.rowData.toMutableList().apply { removeAt(index) }
+ it.copy(
+ rows = rows,
+ rowData = rowData,
+ )
+ }
+ }
+ }
+
+ fun addRow(type: MetaRowType): Job =
+ viewModelScope.launchIO {
+ val id = idCounter++
+ val newRow =
+ when (type) {
+ MetaRowType.CONTINUE_WATCHING -> {
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.continue_watching),
+ config = ContinueWatching(),
+ )
+ }
+
+ MetaRowType.NEXT_UP -> {
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.next_up),
+ config = NextUp(),
+ )
+ }
+
+ MetaRowType.COMBINED_CONTINUE_WATCHING -> {
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.combine_continue_next),
+ config = ContinueWatchingCombined(),
+ )
+ }
+
+ MetaRowType.FAVORITES,
+ MetaRowType.COLLECTION,
+ MetaRowType.PLAYLIST,
+ -> {
+ throw IllegalArgumentException("Should use a different addRow() instead")
+ }
+
+ MetaRowType.DISCOVER -> {
+ TODO()
+ }
+ }
+ updateState {
+ it.copy(
+ loading = LoadingState.Loading,
+ rows = it.rows.toMutableList().apply { add(newRow) },
+ )
+ }
+ fetchRowData()
+ }
+
+ fun addRow(
+ library: Library,
+ rowType: LibraryRowType,
+ ): Job =
+ viewModelScope.launchIO {
+ val id = idCounter++
+ val newRow =
+ when (rowType) {
+ LibraryRowType.RECENTLY_ADDED -> {
+ val title =
+ library.name.let { context.getString(R.string.recently_added_in, it) }
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config = RecentlyAdded(library.itemId),
+ )
+ }
+
+ LibraryRowType.RECENTLY_RELEASED -> {
+ val title =
+ library.name.let {
+ context.getString(
+ R.string.recently_released_in,
+ it,
+ )
+ }
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config = RecentlyReleased(library.itemId),
+ )
+ }
+
+ LibraryRowType.GENRES -> {
+ val title = library.name.let { context.getString(R.string.genres_in, it) }
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config = Genres(library.itemId),
+ )
+ }
+
+ LibraryRowType.SUGGESTIONS -> {
+ val title =
+ library.name.let { context.getString(R.string.suggestions_for, it) }
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config = Suggestions(library.itemId),
+ )
+ }
+
+ LibraryRowType.TV_CHANNELS -> {
+ val title = context.getString(R.string.channels)
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config =
+ TvChannels(
+ viewOptions = HomeRowViewOptions.liveTvDefault,
+ ),
+ )
+ }
+
+ LibraryRowType.TV_PROGRAMS -> {
+ val title = context.getString(R.string.watch_live)
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config = TvPrograms(),
+ )
+ }
+
+ LibraryRowType.RECENTLY_RECORDED -> {
+ val title = context.getString(R.string.recently_recorded)
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config = RecentlyAdded(library.itemId),
+ )
+ }
+
+ LibraryRowType.COLLECTION,
+ LibraryRowType.PLAYLIST,
+ -> {
+ throw IllegalArgumentException("Use different addRow")
+ }
+ }
+ updateState {
+ it.copy(
+ loading = LoadingState.Loading,
+ rows = it.rows.toMutableList().apply { add(newRow) },
+ )
+ }
+ fetchRowData()
+ }
+
+ fun addFavoriteRow(type: BaseItemKind): Job =
+ viewModelScope.launchIO {
+ Timber.v("Adding favorite row for $type")
+ val id = idCounter++
+ val newRow =
+ HomeRowConfigDisplay(
+ id = id,
+ title =
+ context.getString(
+ R.string.favorite_items,
+ context.getString(favoriteOptions[type]!!),
+ ),
+ config = HomeRowConfig.Favorite(type),
+ )
+ updateState {
+ it.copy(
+ loading = LoadingState.Loading,
+ rows = it.rows.toMutableList().apply { add(newRow) },
+ )
+ }
+ fetchRowData()
+ }
+
+ fun addRow(
+ type: BaseItemKind,
+ parent: BaseItem,
+ ) = viewModelScope.launchIO {
+ Timber.v("Adding %s row for %s", type, parent.id)
+ val id = idCounter++
+ val newRow =
+ HomeRowConfigDisplay(
+ id = id,
+ title = parent.name ?: "",
+ config =
+ HomeRowConfig.ByParent(
+ parentId = parent.id,
+ recursive = true,
+ ),
+ )
+ updateState {
+ it.copy(
+ loading = LoadingState.Loading,
+ rows = it.rows.toMutableList().apply { add(newRow) },
+ )
+ }
+ fetchRowData()
+ }
+
+ fun updateViewOptions(
+ rowId: Int,
+ viewOptions: HomeRowViewOptions,
+ ) {
+ viewModelScope.launchIO {
+ var fetchData = false
+ updateState {
+ val index = it.rows.indexOfFirst { it.id == rowId }
+ val config = it.rows[index].config
+ val newRowConfig = config.updateViewOptions(viewOptions)
+ val newRow = it.rows[index].copy(config = newRowConfig)
+ if (config.viewOptions.useSeries != viewOptions.useSeries) {
+ fetchData = true
+ }
+ it.copy(
+ rows =
+ it.rows.toMutableList().apply {
+ set(index, newRow)
+ },
+ rowData =
+ it.rowData.toMutableList().apply {
+ val row = it.rowData[index]
+ val newRow =
+ if (row is HomeRowLoadingState.Success) {
+ row.copy(viewOptions = viewOptions)
+ } else {
+ row
+ }
+ set(index, newRow)
+ },
+ )
+ }
+ if (fetchData) {
+ fetchRowData()
+ }
+ }
+ }
+
+ fun updateViewOptionsForAll(viewOptions: HomeRowViewOptions) {
+ viewModelScope.launchIO {
+ updateState {
+ it.copy(
+ rowData =
+ it.rowData.toMutableList().map { row ->
+ if (row is HomeRowLoadingState.Success) {
+ row.copy(viewOptions = viewOptions)
+ } else {
+ row
+ }
+ },
+ )
+ }
+ }
+ }
+
+ fun saveToRemote() {
+ viewModelScope.launchIO {
+ serverRepository.currentUser.value?.let { user ->
+ Timber.d("Saving home settings to remote")
+ val rows = state.value.rows.map { it.config }
+ val settings =
+ HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
+ try {
+ Timber.d("saveToRemote")
+ homeSettingsService.saveToServer(user.id, settings)
+ showSaveToast()
+ } catch (ex: Exception) {
+ Timber.e(ex)
+ showToast(context, "Error saving: ${ex.localizedMessage}")
+ }
+ }
+ }
+ }
+
+ fun loadFromRemote() {
+ viewModelScope.launchIO {
+ serverRepository.currentUser.value?.let { user ->
+ Timber.d("Loading home settings from remote")
+ try {
+ _state.update { it.copy(loading = LoadingState.Loading) }
+ val result = homeSettingsService.loadFromServer(user.id)
+ if (result != null) {
+ Timber.v("Got remote settings")
+ val newRows =
+ result.rows.mapIndexed { index, config ->
+ homeSettingsService.resolve(index, config)
+ }
+ idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
+ _state.update {
+ it.copy(rows = newRows)
+ }
+ } else {
+ Timber.v("No remote settings")
+ showToast(context, "No server-side settings found")
+ }
+ fetchRowData()
+ } catch (ex: UnsupportedHomeSettingsVersionException) {
+ // TODO
+ Timber.w(ex)
+ showToast(context, "Error: ${ex.localizedMessage}")
+ } catch (ex: Exception) {
+ Timber.e(ex)
+ showToast(context, "Error: ${ex.localizedMessage}")
+ }
+ }
+ }
+ }
+
+ fun loadFromRemoteWeb() {
+ viewModelScope.launchIO {
+ serverRepository.currentUser.value?.let { user ->
+ Timber.d("Loading home settings from web")
+ try {
+ _state.update { it.copy(loading = LoadingState.Loading) }
+ val result = homeSettingsService.parseFromWebConfig(user.id)
+ if (result != null) {
+ Timber.v("Got web settings")
+ idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
+ _state.update {
+ it.copy(rows = result.rows)
+ }
+ } else {
+ Timber.v("No web settings")
+ showToast(context, "No server-side web settings found")
+ }
+ fetchRowData()
+ } catch (ex: Exception) {
+ Timber.e(ex)
+ showToast(context, "Error: ${ex.localizedMessage}")
+ }
+ }
+ }
+ }
+
+ fun saveToLocal() {
+ // This uses injected ioScope so that it will still run when the page is closing
+ ioScope.launchIO {
+ serverRepository.currentUser.value?.let { user ->
+ val rows = state.value.rows.map { it.config }
+ val settings =
+ HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
+ try {
+ Timber.d("saveToLocal")
+ // Only save if there are changes based on original source
+ val shouldSave =
+ if (originalLocalSettings != null) {
+ originalLocalSettings != settings
+ } else if (originalRemoteSettings != null) {
+ originalRemoteSettings != settings
+ } else {
+ true
+ }
+ if (shouldSave) {
+ homeSettingsService.saveToLocal(user.id, settings)
+ homeSettingsService.updateCurrent(settings)
+ showSaveToast()
+ } else {
+ Timber.d("No changes")
+ }
+ } catch (ex: UnsupportedHomeSettingsVersionException) {
+ Timber.w(ex, "Overwriting local settings")
+ homeSettingsService.saveToLocal(user.id, settings)
+ showSaveToast()
+ } catch (ex: Exception) {
+ Timber.e(ex)
+ showToast(context, "Error saving: ${ex.localizedMessage}")
+ }
+ }
+ }
+ }
+
+ private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) {
+ _state.update {
+ update.invoke(it)
+ }
+ homeSettingsService.currentSettings.update { HomePageResolvedSettings(state.value.rows) }
+ }
+
+ fun resizeCards(relative: Int) {
+ viewModelScope.launchIO {
+ updateState {
+ val newRows =
+ it.rows.toMutableList().map { row ->
+ val vo = row.config.viewOptions
+ val newVo = vo.copy(heightDp = vo.heightDp + (4 * relative))
+ row.copy(config = row.config.updateViewOptions(newVo))
+ }
+ it.copy(
+ rows = newRows,
+ rowData =
+ it.rowData.toMutableList().mapIndexed { index, row ->
+ if (row is HomeRowLoadingState.Success) {
+ row.copy(viewOptions = newRows[index].config.viewOptions)
+ } else {
+ row
+ }
+ },
+ )
+ }
+ }
+ }
+
+ fun resetToDefault() =
+ viewModelScope.launchIO {
+ val userId = serverRepository.currentUser.value?.id ?: return@launchIO
+ _state.update { it.copy(loading = LoadingState.Loading) }
+ val result = homeSettingsService.createDefault(userId)
+ idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
+ _state.update {
+ it.copy(rows = result.rows, rowData = emptyList())
+ }
+ fetchRowData()
+ }
+
+ private suspend fun showSaveToast() =
+ showToast(
+ context,
+ context.getString(R.string.settings_saved),
+ Toast.LENGTH_SHORT,
+ )
+
+ fun applyPreset(preset: HomeRowPresets) {
+ _state.update { it.copy(loading = LoadingState.Loading) }
+ viewModelScope.launchIO {
+ val state = state.value
+
+ val typeCache = mutableMapOf()
+
+ suspend fun getCollectionType(itemId: UUID): CollectionType =
+ typeCache.getOrPut(itemId) {
+ state.libraries
+ .firstOrNull { it.itemId == itemId }
+ ?.collectionType
+ ?: api.userLibraryApi
+ .getItem(itemId)
+ .content.collectionType ?: CollectionType.UNKNOWN
+ } ?: CollectionType.UNKNOWN
+
+ val newRows =
+ state.rows.map {
+ val newConfig =
+ when (it.config) {
+ is ContinueWatching,
+ is NextUp,
+ is ContinueWatchingCombined,
+ -> {
+ it.config.updateViewOptions(preset.continueWatching)
+ }
+
+ is HomeRowConfig.ByParent -> {
+ val collectionType = getCollectionType(it.config.parentId)
+ val viewOptions = preset.getByCollectionType(collectionType)
+ it.config.updateViewOptions(viewOptions)
+ }
+
+ is HomeRowConfig.Favorite -> {
+ val viewOptions =
+ when (it.config.kind) {
+ BaseItemKind.MOVIE -> preset.movieLibrary
+ BaseItemKind.SERIES -> preset.tvLibrary
+ BaseItemKind.EPISODE -> preset.continueWatching
+ BaseItemKind.VIDEO -> preset.videoLibrary
+ BaseItemKind.PLAYLIST -> preset.playlist
+ BaseItemKind.PERSON -> preset.movieLibrary
+ else -> preset.movieLibrary
+ }
+ it.config.updateViewOptions(viewOptions)
+ }
+
+ is Genres -> {
+ it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize))
+ }
+
+ is HomeRowConfig.GetItems -> {
+ it.config
+ }
+
+ is RecentlyAdded -> {
+ val collectionType = getCollectionType(it.config.parentId)
+ val viewOptions = preset.getByCollectionType(collectionType)
+ it.config.updateViewOptions(viewOptions)
+ }
+
+ is RecentlyReleased -> {
+ val collectionType = getCollectionType(it.config.parentId)
+ val viewOptions = preset.getByCollectionType(collectionType)
+ it.config.updateViewOptions(viewOptions)
+ }
+
+ is HomeRowConfig.Recordings -> {
+ it.config.updateViewOptions(preset.tvLibrary)
+ }
+
+ is Suggestions -> {
+ val collectionType = getCollectionType(it.config.parentId)
+ val viewOptions = preset.getByCollectionType(collectionType)
+ it.config.updateViewOptions(viewOptions)
+ }
+
+ is HomeRowConfig.TvPrograms -> {
+ it.config.updateViewOptions(preset.liveTv)
+ }
+
+ is HomeRowConfig.TvChannels -> {
+ it.config.updateViewOptions(preset.liveTv)
+ }
+ }
+ it.copy(config = newConfig)
+ }
+
+ _state.update {
+ it.copy(
+ loading = LoadingState.Success,
+ rows = newRows,
+ rowData =
+ it.rowData.toMutableList().mapIndexed { index, row ->
+ if (row is HomeRowLoadingState.Success) {
+ row.copy(viewOptions = newRows[index].config.viewOptions)
+ } else {
+ row
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+
+data class HomePageSettingsState(
+ val loading: LoadingState,
+ val rows: List,
+ val rowData: List,
+ val libraries: List,
+) {
+ companion object {
+ val EMPTY =
+ HomePageSettingsState(
+ LoadingState.Pending,
+ emptyList(),
+ emptyList(),
+ emptyList(),
+ )
+ }
+}
+
+@Immutable
+@Serializable
+data class Library(
+ @Serializable(UUIDSerializer::class) val itemId: UUID,
+ val name: String,
+ val type: BaseItemKind,
+ val collectionType: CollectionType,
+ val isRecordingFolder: Boolean,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
index 2ada8504..5f01f20d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
@@ -6,18 +6,21 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -32,7 +35,9 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.serialization.NavBackStackSerializer
import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.navigation3.ui.NavDisplay
+import androidx.tv.material3.DrawerValue
import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.rememberDrawerState
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transitionFactory
@@ -50,8 +55,8 @@ import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
// Top scrim configuration for text readability (clock, season tabs)
-private const val TOP_SCRIM_ALPHA = 0.55f
-private const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height
+const val TOP_SCRIM_ALPHA = 0.55f
+const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height
@HiltViewModel
class ApplicationContentViewModel
@@ -91,6 +96,7 @@ fun ApplicationContent(
navigationManager.backStack = backStack
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
+ val drawerState = rememberDrawerState(DrawerValue.Closed)
Box(
modifier = modifier,
) {
@@ -181,9 +187,15 @@ fun ApplicationContent(
.align(Alignment.TopEnd)
.fillMaxHeight(.7f)
.fillMaxWidth(.7f)
- .alpha(.95f)
+ .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent {
drawContent()
+ if (drawerState.isOpen) {
+ drawRect(
+ brush = SolidColor(Color.Black),
+ alpha = .75f,
+ )
+ }
// Subtle top scrim for system UI readability (clock, tabs)
if (enableTopScrim) {
drawRect(
@@ -220,6 +232,7 @@ fun ApplicationContent(
)
}
}
+ val navDrawerListState = rememberLazyListState()
NavDisplay(
backStack = navigationManager.backStack,
onBack = { navigationManager.goBack() },
@@ -245,6 +258,8 @@ fun ApplicationContent(
preferences = preferences,
user = user,
server = server,
+ drawerState = drawerState,
+ navDrawerListState = navDrawerListState,
onClearBackdrop = viewModel::clearBackdrop,
modifier = Modifier.fillMaxSize(),
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
index 426d5328..59d102c4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
@@ -33,11 +33,19 @@ sealed class Destination(
val id: Long = 0L,
) : Destination()
+ @Serializable
+ data object HomeSettings : Destination(true)
+
@Serializable
data class Settings(
val screen: PreferenceScreenOption,
) : Destination(true)
+ @Serializable
+ data class SubtitleSettings(
+ val hdr: Boolean,
+ ) : Destination(true)
+
@Serializable
data object Search : Destination()
@@ -100,6 +108,16 @@ sealed class Destination(
val itemIds: List,
) : Destination(false)
+ @Serializable
+ data class Slideshow(
+ val parentId: UUID,
+ val index: Int,
+ val filter: CollectionFolderFilter,
+ val sortAndDirection: SortAndDirection,
+ val recursive: Boolean,
+ val startSlideshow: Boolean,
+ ) : Destination(true)
+
@Serializable
data object Favorites : Destination(false)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
index 70ef71d2..0888e721 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
@@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
+import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist
import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings
import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv
@@ -32,9 +33,12 @@ import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
import com.github.damontecres.wholphin.ui.main.HomePage
import com.github.damontecres.wholphin.ui.main.SearchPage
+import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
+import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
+import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import timber.log.Timber
@@ -60,6 +64,10 @@ fun DestinationContent(
)
}
+ is Destination.HomeSettings -> {
+ HomeSettingsPage(modifier)
+ }
+
is Destination.PlaybackList,
is Destination.Playback,
-> {
@@ -78,6 +86,14 @@ fun DestinationContent(
)
}
+ is Destination.SubtitleSettings -> {
+ SubtitleStylePage(
+ preferences.appPreferences,
+ destination.hdr,
+ modifier,
+ )
+ }
+
is Destination.SeriesOverview -> {
SeriesOverview(
preferences = preferences,
@@ -105,7 +121,9 @@ fun DestinationContent(
)
}
- BaseItemKind.VIDEO -> {
+ BaseItemKind.VIDEO,
+ BaseItemKind.MUSIC_VIDEO,
+ -> {
// TODO Use VideoDetails
MovieDetails(
preferences,
@@ -186,9 +204,19 @@ fun DestinationContent(
)
}
+ BaseItemKind.PHOTO_ALBUM -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
+ CollectionFolderPhotoAlbum(
+ preferences = preferences,
+ itemId = destination.itemId,
+ recursive = true,
+ modifier = modifier,
+ )
+ }
+
else -> {
Timber.w("Unsupported item type: ${destination.type}")
- Text("Unsupported item type: ${destination.type}")
+ Text("Unsupported item type: ${destination.type}", modifier)
}
}
}
@@ -225,6 +253,12 @@ fun DestinationContent(
)
}
+ is Destination.Slideshow -> {
+ SlideshowPage(
+ slideshow = destination,
+ )
+ }
+
Destination.Favorites -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
FavoritesPage(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
index f38d50bd..f0276ffd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
@@ -2,7 +2,9 @@ package com.github.damontecres.wholphin.ui.nav
import android.content.Context
import androidx.activity.compose.BackHandler
-import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.animateIntOffsetAsState
+import androidx.compose.animation.core.spring
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
@@ -12,10 +14,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Home
@@ -24,26 +28,27 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.drawBehind
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -56,29 +61,25 @@ import androidx.tv.material3.DrawerValue
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
-import androidx.tv.material3.ModalNavigationDrawer
-import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.NavigationDrawerItemDefaults
import androidx.tv.material3.NavigationDrawerScope
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
-import androidx.tv.material3.rememberDrawerState
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
+import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
-import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.ifElse
-import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.setup.UserIconCardImage
@@ -86,13 +87,7 @@ import com.github.damontecres.wholphin.ui.spacedByWithFooter
import com.github.damontecres.wholphin.ui.theme.LocalTheme
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
-import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.CollectionType
@@ -105,42 +100,66 @@ class NavDrawerViewModel
@Inject
constructor(
private val api: ApiClient,
- private val navDrawerItemRepository: NavDrawerItemRepository,
+ private val navDrawerService: NavDrawerService,
val navigationManager: NavigationManager,
val setupNavigationManager: SetupNavigationManager,
val backdropService: BackdropService,
- private val seerrServerRepository: SeerrServerRepository,
) : ViewModel() {
- val moreLibraries = MutableLiveData>(null)
- val libraries = MutableLiveData>(listOf())
+ val state = navDrawerService.state
val selectedIndex = MutableLiveData(-1)
- val showMore = MutableLiveData(false)
+ val moreExpanded = MutableLiveData(false)
- init {
- seerrServerRepository.active
- .onEach {
- init()
- }.launchIn(viewModelScope)
+ fun onClickDrawerItem(
+ index: Int,
+ item: NavDrawerItem,
+ ) {
+ if (item !is NavDrawerItem.More) setShowMore(false)
+ when (item) {
+ NavDrawerItem.Favorites -> {
+ setIndex(index)
+ navigationManager.navigateToFromDrawer(
+ Destination.Favorites,
+ )
+ }
+
+ NavDrawerItem.More -> {
+ setShowMore(!moreExpanded.value!!)
+ }
+
+ NavDrawerItem.Discover -> {
+ setIndex(index)
+ navigationManager.navigateToFromDrawer(
+ Destination.Discover,
+ )
+ }
+
+ is ServerNavDrawerItem -> {
+ setIndex(index)
+ navigationManager.navigateToFromDrawer(item.destination)
+ }
+ }
}
- fun init() {
- viewModelScope.launchIO {
- val all = navDrawerItemRepository.getNavDrawerItems()
- val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
- val moreLibraries = all.toMutableList().apply { removeAll(libraries) }
+ fun setIndex(index: Int) {
+ selectedIndex.value = index
+ }
- withContext(Dispatchers.Main) {
- this@NavDrawerViewModel.moreLibraries.value = moreLibraries
- this@NavDrawerViewModel.libraries.value = libraries
- }
+ fun setShowMore(value: Boolean) {
+ moreExpanded.value = value
+ }
+
+ fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
+
+ fun updateSelectedIndex() {
+ viewModelScope.launchDefault {
val asDestinations =
(
- libraries +
+ state.value.items +
listOf(
NavDrawerItem.More,
NavDrawerItem.Discover,
- ) + moreLibraries
+ ) + state.value.moreItems
).map {
if (it is ServerNavDrawerItem) {
it.destination
@@ -179,47 +198,6 @@ class NavDrawerViewModel
}
}
}
-
- fun onClickDrawerItem(
- index: Int,
- item: NavDrawerItem,
- ) {
- if (item !is NavDrawerItem.More) setShowMore(false)
- when (item) {
- NavDrawerItem.Favorites -> {
- setIndex(index)
- navigationManager.navigateToFromDrawer(
- Destination.Favorites,
- )
- }
-
- NavDrawerItem.More -> {
- setShowMore(!showMore.value!!)
- }
-
- NavDrawerItem.Discover -> {
- setIndex(index)
- navigationManager.navigateToFromDrawer(
- Destination.Discover,
- )
- }
-
- is ServerNavDrawerItem -> {
- setIndex(index)
- navigationManager.navigateToFromDrawer(item.destination)
- }
- }
- }
-
- fun setIndex(index: Int) {
- selectedIndex.value = index
- }
-
- fun setShowMore(value: Boolean) {
- showMore.value = value
- }
-
- fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
}
sealed interface NavDrawerItem {
@@ -255,9 +233,13 @@ data class ServerNavDrawerItem(
val destination: Destination,
val type: CollectionType,
) : NavDrawerItem {
- override val id: String = "s_" + itemId.toServerString()
+ override val id: String = getId(itemId)
override fun name(context: Context): String = name
+
+ companion object {
+ fun getId(itemId: UUID) = "s_" + itemId.toServerString()
+ }
}
/**
@@ -269,6 +251,8 @@ fun NavDrawer(
preferences: UserPreferences,
user: JellyfinUser,
server: JellyfinServer,
+ drawerState: DrawerState,
+ navDrawerListState: LazyListState,
onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier,
viewModel: NavDrawerViewModel =
@@ -277,76 +261,58 @@ fun NavDrawer(
key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either
),
) {
- val drawerState = rememberDrawerState(DrawerValue.Closed)
- val listState = rememberLazyListState()
+ LaunchedEffect(Unit) { viewModel.updateSelectedIndex() }
val scope = rememberCoroutineScope()
val context = LocalContext.current
+ val density = LocalDensity.current
val focusRequester = remember { FocusRequester() }
- val searchFocusRequester = remember { FocusRequester() }
// If the user presses back while on the home page, open the nav drawer, another back press will quit the app
BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) {
drawerState.setValue(DrawerValue.Open)
focusRequester.requestFocus()
}
- val moreLibraries by viewModel.moreLibraries.observeAsState(listOf())
- val libraries by viewModel.libraries.observeAsState(listOf())
- LaunchedEffect(Unit) { viewModel.init() }
-
- val showMore by viewModel.showMore.observeAsState(false)
- // A negative index is a built in page, >=0 is a library
+ val state by viewModel.state.collectAsState()
+ val moreExpanded by viewModel.moreExpanded.observeAsState(false)
+ // A negative index is a built-in page, >=0 is a library
val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
- BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) {
+ BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) {
viewModel.setShowMore(false)
}
- val closedDrawerWidth = NavigationDrawerItemDefaults.CollapsedDrawerItemWidth
- val drawerBackground by animateColorAsState(
- if (drawerState.isOpen) {
- MaterialTheme.colorScheme.surface
- } else {
- Color.Transparent
- },
+ val closedDrawerWidth = CollapsedDrawerItemWidth
+ val openDrawerWidth = ExpandedDrawerItemWidth
+ val offset by animateIntOffsetAsState(
+ targetValue =
+ IntOffset(
+ x =
+ with(density) {
+ if (drawerState.isOpen) (openDrawerWidth - closedDrawerWidth).roundToPx() else 0
+ },
+ y = 0,
+ ),
+ animationSpec =
+ spring(
+ stiffness = DrawerAnimationStiffness,
+ visibilityThreshold = IntOffset.VisibilityThreshold,
+ ),
)
- val spacedBy = 4.dp
- val config = LocalConfiguration.current
- val density = LocalDensity.current
- val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } }
-
- suspend fun scrollToSelected() {
- val target = selectedIndex + 2
- try {
- if (target !in
- listState.firstVisibleItemIndex..
+ val isOpen = drawerValue.isOpen
+ val spacedBy = 4.dp
+ val searchFocusRequester = remember { FocusRequester() }
+
ProvideTextStyle(MaterialTheme.typography.labelMedium) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(spacedBy),
- modifier =
- Modifier
- .fillMaxHeight()
- .drawBehind {
- drawRect(drawerBackground)
- },
+ modifier = Modifier.fillMaxHeight(),
) {
// Even though some must be clicked, focusing on it should clear other focused items
val interactionSource = remember { MutableInteractionSource() }
@@ -355,7 +321,7 @@ fun NavDrawer(
user = user,
imageUrl = userImageUrl,
serverName = server.name ?: server.url,
- drawerOpen = drawerState.isOpen,
+ drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.setupNavigationManager.navigateTo(
@@ -365,7 +331,7 @@ fun NavDrawer(
modifier = Modifier,
)
LazyColumn(
- state = listState,
+ state = navDrawerListState,
contentPadding = PaddingValues(0.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedByWithFooter(spacedBy),
@@ -380,11 +346,6 @@ fun NavDrawer(
focusRequester.tryRequestFocus()
}
}
- onExit = {
- scope.launch(ExceptionHandler()) {
- scrollToSelected()
- }
- }
}.fillMaxHeight(),
) {
item {
@@ -393,7 +354,7 @@ fun NavDrawer(
text = stringResource(R.string.search),
icon = Icons.Default.Search,
selected = selectedIndex == -2,
- drawerOpen = drawerState.isOpen,
+ drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.setIndex(-2)
@@ -414,7 +375,7 @@ fun NavDrawer(
text = stringResource(R.string.home),
icon = Icons.Default.Home,
selected = selectedIndex == -1,
- drawerOpen = drawerState.isOpen,
+ drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.setIndex(-1)
@@ -432,58 +393,90 @@ fun NavDrawer(
),
)
}
- itemsIndexed(libraries) { index, it ->
- val interactionSource = remember { MutableInteractionSource() }
- NavItem(
- library = it,
- selected = selectedIndex == index,
- moreExpanded = showMore,
- drawerOpen = drawerState.isOpen,
- interactionSource = interactionSource,
- onClick = {
- viewModel.onClickDrawerItem(index, it)
- },
- modifier =
- Modifier
- .ifElse(
- selectedIndex == index,
- Modifier.focusRequester(focusRequester),
- ),
- )
- }
- if (showMore) {
- itemsIndexed(moreLibraries) { index, it ->
- val adjustedIndex = (index + libraries.size + 1)
+ itemsIndexed(state.items) { index, it ->
+ if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
val interactionSource = remember { MutableInteractionSource() }
NavItem(
library = it,
- selected = selectedIndex == adjustedIndex,
- moreExpanded = showMore,
- drawerOpen = drawerState.isOpen,
- onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) },
- containerColor =
- if (drawerState.isOpen) {
- MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
- } else {
- Color.Unspecified
- },
+ selected = selectedIndex == index,
+ moreExpanded = moreExpanded,
+ drawerOpen = isOpen,
interactionSource = interactionSource,
+ onClick = {
+ viewModel.onClickDrawerItem(index, it)
+ },
modifier =
Modifier
.ifElse(
- selectedIndex == adjustedIndex,
+ selectedIndex == index,
Modifier.focusRequester(focusRequester),
),
)
}
}
+ if (state.moreItems.isNotEmpty()) {
+ item {
+ val index = state.items.size
+ val interactionSource = remember { MutableInteractionSource() }
+ NavItem(
+ library = NavDrawerItem.More,
+ selected = selectedIndex == index,
+ moreExpanded = moreExpanded,
+ drawerOpen = isOpen,
+ interactionSource = interactionSource,
+ onClick = {
+ viewModel.onClickDrawerItem(index, NavDrawerItem.More)
+ },
+ modifier =
+ Modifier
+ .ifElse(
+ selectedIndex == index,
+ Modifier.focusRequester(focusRequester),
+ ),
+ )
+ }
+ }
+ if (moreExpanded) {
+ itemsIndexed(state.moreItems) { index, it ->
+ val adjustedIndex =
+ remember(state) { (index + state.items.size + 1) }
+ if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
+ val interactionSource = remember { MutableInteractionSource() }
+ NavItem(
+ library = it,
+ selected = selectedIndex == adjustedIndex,
+ moreExpanded = moreExpanded,
+ drawerOpen = isOpen,
+ onClick = {
+ viewModel.onClickDrawerItem(
+ adjustedIndex,
+ it,
+ )
+ },
+ containerColor =
+ if (isOpen) {
+ MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
+ } else {
+ Color.Unspecified
+ },
+ interactionSource = interactionSource,
+ modifier =
+ Modifier
+ .ifElse(
+ selectedIndex == adjustedIndex,
+ Modifier.focusRequester(focusRequester),
+ ),
+ )
+ }
+ }
+ }
item {
val interactionSource = remember { MutableInteractionSource() }
IconNavItem(
text = stringResource(R.string.settings),
icon = Icons.Default.Settings,
selected = false,
- drawerOpen = drawerState.isOpen,
+ drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.navigationManager.navigateTo(
@@ -501,10 +494,7 @@ fun NavDrawer(
},
) {
Box(
- modifier =
- Modifier
- .padding(start = closedDrawerWidth)
- .fillMaxSize(),
+ modifier = Modifier.fillMaxSize(),
) {
// Drawer content
DestinationContent(
@@ -513,7 +503,10 @@ fun NavDrawer(
onClearBackdrop = onClearBackdrop,
modifier =
Modifier
- .fillMaxSize(),
+ .fillMaxSize()
+ .offset {
+ offset
+ }.padding(start = closedDrawerWidth + 8.dp, end = 16.dp),
)
if (preferences.appPreferences.interfacePreferences.showClock) {
TimeDisplay()
@@ -542,7 +535,7 @@ fun NavigationDrawerScope.ProfileIcon(
name = user.name,
imageUrl = imageUrl,
alpha = if (drawerOpen) 1f else .5f,
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier.size(DrawerIconSize),
)
},
supportingContent = {
@@ -583,7 +576,7 @@ fun NavigationDrawerScope.IconNavItem(
icon,
contentDescription = null,
tint = color,
- modifier = Modifier,
+ modifier = Modifier.size(DrawerIconSize),
)
},
supportingContent =
@@ -673,7 +666,7 @@ fun NavigationDrawerScope.NavItem(
painter = painterResource(icon),
contentDescription = null,
tint = color,
- modifier = Modifier,
+ modifier = Modifier.size(DrawerIconSize),
)
}
}
@@ -700,6 +693,7 @@ fun NavigationDrawerScope.NavItem(
}
@Composable
+@ReadOnlyComposable
fun navItemColor(
selected: Boolean,
focused: Boolean,
@@ -753,4 +747,6 @@ fun navItemColor(
}
}
-val DrawerState.isOpen: Boolean get() = this.currentValue == DrawerValue.Open
+val DrawerState.isOpen: Boolean get() = this.currentValue.isOpen
+
+val DrawerValue.isOpen: Boolean get() = this == DrawerValue.Open
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt
new file mode 100644
index 00000000..30b9d1d6
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt
@@ -0,0 +1,229 @@
+/*
+ * This file contains code copied and modified from:
+ * https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-androidx-tv-material-release/tv/tv-material/src/main/java/androidx/tv/material3/
+ *
+ * Their license & required attribution is below.
+ *
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.damontecres.wholphin.ui.nav
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+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
+import androidx.compose.ui.focus.FocusState
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.DrawerState
+import androidx.tv.material3.DrawerValue
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.ListItemDefaults
+import androidx.tv.material3.NavigationDrawerItemColors
+import androidx.tv.material3.NavigationDrawerItemDefaults
+import androidx.tv.material3.NavigationDrawerScope
+import androidx.tv.material3.rememberDrawerState
+
+@Composable
+fun ModalNavigationDrawer(
+ drawerContent: @Composable NavigationDrawerScope.(DrawerValue) -> Unit,
+ modifier: Modifier = Modifier,
+ drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
+ content: @Composable () -> Unit,
+) {
+ Box(modifier = modifier) {
+ DrawerSheet(
+ modifier =
+ Modifier
+ .align(Alignment.CenterStart),
+ drawerState = drawerState,
+ content = drawerContent,
+ )
+
+ content()
+ }
+}
+
+@Composable
+private fun DrawerSheet(
+ modifier: Modifier = Modifier,
+ drawerState: DrawerState = remember { DrawerState() },
+ content: @Composable NavigationDrawerScope.(DrawerValue) -> Unit,
+) {
+ // indicates that the drawer has been set to its initial state and has grabbed focus if
+ // necessary. Controls whether focus is used to decide the state of the drawer going forward.
+ var initializationComplete: Boolean by remember { mutableStateOf(false) }
+ var focusState by remember { mutableStateOf(null) }
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(key1 = drawerState.currentValue) {
+ if (drawerState.currentValue == DrawerValue.Open && focusState?.hasFocus == false) {
+ // used to grab focus if the drawer state is set to Open on start.
+ focusRequester.requestFocus()
+ }
+ initializationComplete = true
+ }
+
+ val internalModifier =
+ Modifier
+ .focusRequester(focusRequester)
+ .animateContentSize(
+ animationSpec =
+ spring(
+ stiffness = DrawerAnimationStiffness,
+ visibilityThreshold = IntSize.VisibilityThreshold,
+ ),
+ ).fillMaxHeight()
+ // adding passed-in modifier here to ensure animateContentSize is called before other
+ // size based modifiers.
+ .then(modifier)
+ .onFocusChanged {
+ focusState = it
+
+ if (initializationComplete) {
+ drawerState.setValue(if (it.hasFocus) DrawerValue.Open else DrawerValue.Closed)
+ }
+ }.focusGroup()
+
+ Box(modifier = internalModifier) {
+ NavigationDrawerScopeImpl(drawerState.currentValue == DrawerValue.Open).apply {
+ content(drawerState.currentValue)
+ }
+ }
+}
+
+internal class NavigationDrawerScopeImpl(
+ override val hasFocus: Boolean,
+) : NavigationDrawerScope
+
+internal val CollapsedDrawerItemWidth = 64.dp
+internal val ExpandedDrawerItemWidth = 224.dp
+internal val DrawerIconSize = 24.dp
+internal val DrawerIconPadding = (ListItemDefaults.IconSize - DrawerIconSize) / 2
+internal val DrawerAnimationStiffness = Spring.StiffnessMedium
+
+@Composable
+internal fun NavigationDrawerScope.NavigationDrawerItem(
+ selected: Boolean,
+ onClick: () -> Unit,
+ leadingContent: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ onLongClick: (() -> Unit)? = null,
+ supportingContent: (@Composable () -> Unit)? = null,
+ trailingContent: (@Composable () -> Unit)? = null,
+ tonalElevation: Dp = NavigationDrawerItemDefaults.NavigationDrawerItemElevation,
+ colors: NavigationDrawerItemColors = NavigationDrawerItemDefaults.colors(),
+ interactionSource: MutableInteractionSource? = null,
+ content: @Composable () -> Unit,
+) {
+ val animatedWidth by
+ animateDpAsState(
+ targetValue =
+ if (hasFocus) {
+ ExpandedDrawerItemWidth
+ } else {
+ CollapsedDrawerItemWidth
+ },
+ label = "NavigationDrawerItem width open/closed state of the drawer item",
+ )
+ val navDrawerItemHeight =
+ if (supportingContent == null) {
+ NavigationDrawerItemDefaults.ContainerHeightOneLine
+ } else {
+ NavigationDrawerItemDefaults.ContainerHeightTwoLine
+ }
+
+ ListItem(
+ selected = selected,
+ onClick = onClick,
+ headlineContent = content,
+ leadingContent = {
+ Box(
+ Modifier
+ .padding(horizontal = DrawerIconPadding)
+ .size(DrawerIconSize),
+ ) { leadingContent() }
+ },
+ trailingContent = trailingContent,
+ supportingContent = supportingContent,
+ modifier =
+ modifier
+ .layout { measurable, constraints ->
+ val width = animatedWidth.roundToPx()
+ val height = navDrawerItemHeight.roundToPx()
+ val placeable =
+ measurable.measure(
+ constraints.copy(
+ minWidth = width,
+ maxWidth = width,
+ minHeight = height,
+ maxHeight = height,
+ ),
+ )
+ layout(placeable.width, placeable.height) { placeable.place(0, 0) }
+ },
+ enabled = enabled,
+ onLongClick = onLongClick,
+ tonalElevation = tonalElevation,
+ colors = colors.toToggleableListItemColors(hasFocus),
+ scale = ListItemDefaults.scale(1f, 1f),
+ interactionSource = interactionSource,
+ )
+}
+
+@Composable
+private fun NavigationDrawerItemColors.toToggleableListItemColors(doesNavigationDrawerHaveFocus: Boolean) =
+ ListItemDefaults.colors(
+ containerColor = containerColor,
+ contentColor = if (doesNavigationDrawerHaveFocus) contentColor else inactiveContentColor,
+ focusedContainerColor = focusedContainerColor,
+ focusedContentColor = focusedContentColor,
+ pressedContainerColor = pressedContainerColor,
+ pressedContentColor = pressedContentColor,
+ selectedContainerColor = selectedContainerColor,
+ selectedContentColor = selectedContentColor,
+ disabledContainerColor = disabledContainerColor,
+ disabledContentColor =
+ if (doesNavigationDrawerHaveFocus) {
+ disabledContentColor
+ } else {
+ disabledInactiveContentColor
+ },
+ focusedSelectedContainerColor = focusedSelectedContainerColor,
+ focusedSelectedContentColor = focusedSelectedContentColor,
+ pressedSelectedContainerColor = pressedSelectedContainerColor,
+ pressedSelectedContentColor = pressedSelectedContentColor,
+ )
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt
deleted file mode 100644
index 7fc97366..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.github.damontecres.wholphin.ui.playback
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.ui.platform.LocalContext
-import androidx.media3.common.Player
-import androidx.media3.common.Player.Listener
-import com.github.damontecres.wholphin.ui.findActivity
-import com.github.damontecres.wholphin.ui.keepScreenOn
-
-/**
- * Starts a [Player.Listener] that ensures the screen stays on without a screen saber during playback
- *
- * This will clean up the listener when disposed
- */
-@Composable
-fun AmbientPlayerListener(player: Player) {
- val context = LocalContext.current
- DisposableEffect(player) {
- val listener =
- object : Listener {
- override fun onIsPlayingChanged(isPlaying: Boolean) {
- context.findActivity()?.keepScreenOn(isPlaying)
- }
- }
- player.addListener(listener)
- onDispose {
- player.removeListener(listener)
- context.findActivity()?.keepScreenOn(false)
- }
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt
index 2b7d61bc..e8dc7d1d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt
@@ -1,7 +1,12 @@
package com.github.damontecres.wholphin.ui.playback
+import androidx.core.net.toUri
+import androidx.media3.common.MediaMetadata
+import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
+import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.TrickplayInfo
+import org.jellyfin.sdk.model.extensions.ticks
data class CurrentMediaInfo(
val sourceId: String?,
@@ -15,3 +20,22 @@ data class CurrentMediaInfo(
val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null)
}
}
+
+fun BaseItem.toMediaMetadata(imageUrl: String?): MediaMetadata =
+ MediaMetadata
+ .Builder()
+ .setTitle(title)
+ .setSubtitle(subtitle)
+ .setReleaseYear(data.productionYear)
+ .setDescription(data.overview)
+ .setArtworkUri(imageUrl?.toUri())
+ .setDurationMs(data.runTimeTicks?.ticks?.inWholeMilliseconds)
+ .setMediaType(
+ when (type) {
+ BaseItemKind.MOVIE -> MediaMetadata.MEDIA_TYPE_MOVIE
+ BaseItemKind.EPISODE -> MediaMetadata.MEDIA_TYPE_TV_SHOW
+ BaseItemKind.VIDEO -> MediaMetadata.MEDIA_TYPE_VIDEO
+ BaseItemKind.TV_CHANNEL, BaseItemKind.CHANNEL, BaseItemKind.LIVE_TV_CHANNEL -> MediaMetadata.MEDIA_TYPE_TV_CHANNEL
+ else -> MediaMetadata.MEDIA_TYPE_VIDEO
+ },
+ ).build()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt
index b3813ed5..e1a291e1 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt
@@ -102,7 +102,7 @@ fun DownloadSubtitlesContent(
.padding(PaddingValues(24.dp)),
) {
Text(
- text = "Search & download subtitles",
+ text = stringResource(R.string.search_and_download_subtitles),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -113,7 +113,7 @@ fun DownloadSubtitlesContent(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
- text = "Language",
+ text = stringResource(R.string.language),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -137,7 +137,7 @@ fun DownloadSubtitlesContent(
}
if (dialogItems.isEmpty()) {
Text(
- text = "No remote subtitles were found",
+ text = stringResource(R.string.no_subtitles_found),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt
index 55fd5424..474c7932 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt
@@ -1,20 +1,22 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.ui.layout.ContentScale
+import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.PrefContentScale
import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.CollectionType
val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0")
val playbackScaleOptions =
mapOf(
- ContentScale.Fit to "Fit",
- ContentScale.None to "None",
- ContentScale.Crop to "Crop",
+ ContentScale.Fit to R.string.content_scale_fit,
+ ContentScale.None to R.string.none,
+ ContentScale.Crop to R.string.content_scale_crop,
// ContentScale.Inside to "Inside",
- ContentScale.FillBounds to "Fill",
- ContentScale.FillWidth to "Fill Width",
- ContentScale.FillHeight to "Fill Height",
+ ContentScale.FillBounds to R.string.content_scale_fill,
+ ContentScale.FillWidth to R.string.content_scale_fill_width,
+ ContentScale.FillHeight to R.string.content_scale_fill_height,
)
val PrefContentScale.scale: ContentScale
@@ -81,3 +83,20 @@ val BaseItemKind.playable: Boolean
BaseItemKind.YEAR,
-> false
}
+
+fun getTypeFor(collectionType: CollectionType): BaseItemKind? =
+ when (collectionType) {
+ CollectionType.UNKNOWN -> null
+ CollectionType.MOVIES -> BaseItemKind.MOVIE
+ CollectionType.TVSHOWS -> BaseItemKind.SERIES
+ CollectionType.MUSIC -> BaseItemKind.AUDIO
+ CollectionType.MUSICVIDEOS -> BaseItemKind.MUSIC_VIDEO
+ CollectionType.TRAILERS -> BaseItemKind.TRAILER
+ CollectionType.HOMEVIDEOS -> BaseItemKind.VIDEO
+ CollectionType.BOXSETS -> BaseItemKind.BOX_SET
+ CollectionType.BOOKS -> BaseItemKind.BOOK
+ CollectionType.PHOTOS -> BaseItemKind.PHOTO_ALBUM
+ CollectionType.LIVETV -> BaseItemKind.LIVE_TV_CHANNEL
+ CollectionType.PLAYLISTS -> BaseItemKind.PLAYLIST
+ CollectionType.FOLDERS -> BaseItemKind.FOLDER
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
index 04ff620a..c6d7b622 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
@@ -144,7 +144,9 @@ fun PlaybackDialog(
BottomDialogItem(
data = PlaybackDialogType.VIDEO_SCALE,
headline = stringResource(R.string.video_scale),
- supporting = playbackScaleOptions[settings.contentScale],
+ supporting =
+ playbackScaleOptions[settings.contentScale]
+ ?.let { stringResource(it) },
),
)
}
@@ -220,7 +222,7 @@ fun PlaybackDialog(
playbackScaleOptions.map { (scale, name) ->
BottomDialogItem(
data = scale,
- headline = name,
+ headline = stringResource(name),
supporting = null,
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt
index f10a0758..5ed82729 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt
@@ -20,6 +20,7 @@ class PlaybackKeyHandler(
private val skipWithLeftRight: Boolean,
private val seekBack: Duration,
private val seekForward: Duration,
+ private val getDurationMs: () -> Long,
private val controllerViewState: ControllerViewState,
private val updateSkipIndicator: (Long) -> Unit,
private val skipBackOnResume: Duration?,
@@ -28,15 +29,22 @@ class PlaybackKeyHandler(
private val onStop: () -> Unit,
private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit,
) {
+ private var leftHandledByRepeat = false
+ private var rightHandledByRepeat = false
+
fun onKeyEvent(it: KeyEvent): Boolean {
if (it.type == KeyEventType.KeyUp) onInteraction.invoke()
- var result = true
if (!controlsEnabled) {
- result = false
+ return false
+ } else if (handleHoldSkip(it)) {
+ return true
} else if (it.type != KeyEventType.KeyUp) {
- result = false
- } else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
+ return false
+ }
+
+ var result = true
+ if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
if (!controllerViewState.controlsVisible) {
if (skipWithLeftRight && isSkipBack(it)) {
updateSkipIndicator(-seekBack.inWholeMilliseconds)
@@ -111,4 +119,84 @@ class PlaybackKeyHandler(
}
return result
}
+
+ private fun handleHoldSkip(event: KeyEvent): Boolean {
+ if (
+ controllerViewState.controlsVisible ||
+ !skipWithLeftRight ||
+ (!isSkipBack(event) && !isSkipForward(event))
+ ) {
+ return false
+ }
+
+ val isBack = isSkipBack(event)
+ return when (event.type) {
+ KeyEventType.KeyDown -> {
+ val repeatCount = event.nativeKeyEvent.repeatCount
+ if (repeatCount > 0) {
+ if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) {
+ setHandledByRepeat(isBack = isBack, handled = false)
+ return true
+ }
+ val multiplier =
+ calculateSeekAccelerationMultiplier(
+ repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT,
+ durationMs = normalizedDurationMs(),
+ )
+ setHandledByRepeat(isBack = isBack, handled = true)
+ seekWithMultiplier(isBack = isBack, multiplier = multiplier)
+ } else {
+ setHandledByRepeat(isBack = isBack, handled = false)
+ }
+ true
+ }
+
+ KeyEventType.KeyUp -> {
+ if (!handledByRepeat(isBack = isBack)) {
+ seekWithMultiplier(isBack = isBack, multiplier = 1)
+ }
+ setHandledByRepeat(isBack = isBack, handled = false)
+ true
+ }
+
+ else -> {
+ false
+ }
+ }
+ }
+
+ private fun seekWithMultiplier(
+ isBack: Boolean,
+ multiplier: Int,
+ ) {
+ if (isBack) {
+ val skipDuration = seekBack * multiplier
+ player.seekBack(skipDuration)
+ updateSkipIndicator(-skipDuration.inWholeMilliseconds)
+ } else {
+ val skipDuration = seekForward * multiplier
+ player.seekForward(skipDuration)
+ updateSkipIndicator(skipDuration.inWholeMilliseconds)
+ }
+ }
+
+ private fun setHandledByRepeat(
+ isBack: Boolean,
+ handled: Boolean,
+ ) {
+ if (isBack) {
+ leftHandledByRepeat = handled
+ } else {
+ rightHandledByRepeat = handled
+ }
+ }
+
+ private fun handledByRepeat(isBack: Boolean): Boolean =
+ if (isBack) {
+ leftHandledByRepeat
+ } else {
+ rightHandledByRepeat
+ }
+
+ private fun normalizedDurationMs(): Long = getDurationMs().coerceAtLeast(0L)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt
index 99313af2..0886590e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt
@@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
@@ -18,10 +19,15 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -32,6 +38,7 @@ 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.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
@@ -251,8 +258,34 @@ fun PlaybackOverlay(
exit = slideOutVertically { it / 2 } + fadeOut(),
) {
if (chapters.isNotEmpty()) {
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+ val chapterIndex =
+ remember {
+ val position = playerControls.currentPosition.milliseconds
+ val index =
+ chapters
+ .indexOfFirst { it.position > position }
+ .minus(1)
+ .let {
+ if (it < 0) {
+ // Didn't find a chapter, so it's either the first or last
+ if (position < chapters.first().position) {
+ 0
+ } else {
+ chapters.lastIndex
+ }
+ } else {
+ it
+ }
+ }.coerceIn(0, chapters.lastIndex)
+ index
+ }
+ val listState = rememberLazyListState(chapterIndex)
val focusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ LaunchedEffect(Unit) {
+ bringIntoViewRequester.bringIntoView()
+ focusRequester.tryRequestFocus()
+ }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
@@ -273,6 +306,7 @@ fun PlaybackOverlay(
style = MaterialTheme.typography.titleLarge,
)
LazyRow(
+ state = listState,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
@@ -302,10 +336,19 @@ fun PlaybackOverlay(
},
interactionSource = interactionSource,
modifier =
- Modifier.ifElse(
- index == 0,
- Modifier.focusRequester(focusRequester),
- ),
+ Modifier
+ .ifElse(
+ index == chapterIndex,
+ Modifier
+ .focusRequester(focusRequester)
+ .bringIntoViewRequester(bringIntoViewRequester),
+ ).ifElse(
+ index == 0,
+ Modifier.focusProperties {
+ // Prevent scrolling left on first card to prevent moving down
+ left = FocusRequester.Cancel
+ },
+ ),
)
}
}
@@ -443,6 +486,12 @@ fun PlaybackOverlay(
text = (seekProgressMs / 1000L).seconds.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
+ modifier =
+ Modifier
+ .background(
+ Color.Black.copy(alpha = 0.6f),
+ shape = RoundedCornerShape(4.dp),
+ ).padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
@@ -525,13 +574,22 @@ fun Controller(
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onNextStateFocus: () -> Unit = {},
) {
+ val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
+ val verticalOffset by animateDpAsState(
+ targetValue = if (seekBarFocused) (-32).dp else 0.dp,
+ label = "TitleBumpOffset",
+ )
+
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.padding(start = 16.dp),
+ modifier =
+ Modifier
+ .padding(start = 16.dp)
+ .offset(y = verticalOffset),
) {
title?.let {
Text(
@@ -554,26 +612,28 @@ fun Controller(
fontSize = subtitleTextSize,
)
}
- if (showClock) {
- var remaining by remember { mutableStateOf((playerControls.duration - playerControls.currentPosition).milliseconds) }
- LaunchedEffect(playerControls) {
- while (isActive) {
- remaining =
- (playerControls.duration - playerControls.currentPosition).milliseconds
- delay(1.seconds)
- }
+
+ var endTimeStr by remember { mutableStateOf("...") }
+ LaunchedEffect(playerControls) {
+ while (isActive) {
+ val remaining =
+ (playerControls.duration - playerControls.currentPosition)
+ .div(playerControls.playbackParameters.speed)
+ .toLong()
+ .milliseconds
+ val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
+ endTimeStr = TimeFormatter.format(endTime)
+ delay(1.seconds)
}
- val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
- val endTimeStr = TimeFormatter.format(endTime)
- Text(
- text = "Ends $endTimeStr",
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.labelLarge,
- modifier =
- Modifier
- .padding(end = 32.dp),
- )
}
+ Text(
+ text = "Ends $endTimeStr",
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.labelLarge,
+ modifier =
+ Modifier
+ .padding(end = 32.dp),
+ )
}
}
// TODO need to move these up a level?
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
index 8e8744fd..a4ce04e2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
@@ -37,6 +37,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -75,6 +76,7 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TextButton
+import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize
@@ -90,7 +92,6 @@ import io.github.peerless2012.ass.media.widget.AssSubtitleView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
-import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID
import kotlin.time.Duration
@@ -168,8 +169,7 @@ fun PlaybackPageContent(
itemId = UUID.randomUUID(),
),
)
- val currentSegment by viewModel.currentSegment.observeAsState(null)
- var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
+ val currentSegment by viewModel.currentSegment.collectAsState()
val cues by viewModel.subtitleCues.observeAsState(listOf())
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
@@ -184,6 +184,7 @@ fun PlaybackPageContent(
LaunchedEffect(player) {
if (playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.IO + ExceptionHandler()) {
+ // MPV can't play HDR, so always use regular settings
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
configuration,
density,
@@ -192,7 +193,6 @@ fun PlaybackPageContent(
}
}
- AmbientPlayerListener(player)
var contentScale by remember(playerBackend) {
mutableStateOf(
if (playerBackend == PlayerBackend.MPV) {
@@ -242,6 +242,7 @@ fun PlaybackPageContent(
skipWithLeftRight = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
+ getDurationMs = { player.duration.coerceAtLeast(0L) },
controllerViewState = controllerViewState,
updateSkipIndicator = updateSkipIndicator,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
@@ -306,10 +307,10 @@ fun PlaybackPageContent(
}
val showSegment =
- !segmentCancelled && currentSegment != null &&
+ currentSegment?.interacted == false &&
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
BackHandler(showSegment) {
- segmentCancelled = true
+ viewModel.updateSegment(currentSegment?.segment?.id, true)
}
Box(
@@ -408,18 +409,31 @@ fun PlaybackPageContent(
onClickPlaylist = {
viewModel.playItemInPlaylist(it)
},
- currentSegment = currentSegment,
+ currentSegment = currentSegment?.segment,
showClock = preferences.appPreferences.interfacePreferences.showClock,
)
}
+ val subtitleSettings =
+ remember(mediaInfo) {
+ Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}")
+ if (mediaInfo?.videoStream?.hdr == true) {
+ preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences
+ } else {
+ preferences.appPreferences.interfacePreferences.subtitlesPreferences
+ }
+ }
+ val subtitleImageOpacity =
+ remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f }
+
// Subtitles
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
+ val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
AndroidView(
factory = { context ->
SubtitleView(context).apply {
- preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
+ subtitleSettings.let {
setStyle(it.toSubtitleStyle())
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f)
@@ -443,10 +457,8 @@ fun PlaybackPageContent(
},
update = {
it.setCues(cues)
- Media3SubtitleOverride(
- preferences.appPreferences.interfacePreferences.subtitlesPreferences
- .calculateEdgeSize(density),
- ).apply(it)
+ Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
+ .apply(it)
it.children.firstOrNull { it is AssSubtitleView }?.let {
(it as? AssSubtitleView)?.apply {
Timber.v("Resize: $playerSize")
@@ -466,7 +478,8 @@ fun PlaybackPageContent(
Modifier
.fillMaxSize(maxSize)
.align(Alignment.TopCenter)
- .background(Color.Transparent),
+ .background(Color.Transparent)
+ .ifElse(isImageSubtitles, Modifier.alpha(subtitleImageOpacity)),
)
}
}
@@ -484,13 +497,12 @@ fun PlaybackPageContent(
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
delay(10.seconds)
- segmentCancelled = true
+ viewModel.updateSegment(segment.segment.id, true)
}
TextButton(
- stringRes = segment.type.skipStringRes,
+ stringRes = segment.segment.type.skipStringRes,
onClick = {
- segmentCancelled = true
- player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
+ viewModel.updateSegment(segment.segment.id, false)
},
modifier = Modifier.focusRequester(focusRequester),
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
index 7e06f5ee..4f652360 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
@@ -41,14 +41,17 @@ import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.DeviceProfileService
+import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.PlaylistCreationResult
import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.services.RefreshRateService
+import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain
@@ -57,7 +60,6 @@ import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
-import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
@@ -96,6 +98,7 @@ import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaStreamType
@@ -138,6 +141,8 @@ class PlaybackViewModel
private val refreshRateService: RefreshRateService,
val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService,
+ private val imageUrlService: ImageUrlService,
+ private val screensaverService: ScreensaverService,
@Assisted private val destination: Destination,
) : ViewModel(),
Player.Listener,
@@ -165,8 +170,7 @@ class PlaybackViewModel
val currentMediaInfo = MutableLiveData(CurrentMediaInfo.EMPTY)
val currentPlayback = MutableStateFlow(null)
val currentItemPlayback = MutableLiveData()
- val currentSegment = EqualityMutableLiveData(null)
- private val autoSkippedSegments = mutableSetOf()
+ val currentSegment = MutableStateFlow(null)
val subtitleCues = MutableLiveData>(listOf())
@@ -188,7 +192,10 @@ class PlaybackViewModel
init {
viewModelScope.launchIO {
- addCloseable { disconnectPlayer() }
+ addCloseable {
+ screensaverService.keepScreenOn(false)
+ disconnectPlayer()
+ }
init()
}
}
@@ -675,7 +682,15 @@ class PlaybackViewModel
MediaItem
.Builder()
.setMediaId(itemId.toString())
- .setUri(mediaUrl.toUri())
+ .setMediaMetadata(
+ item.toMediaMetadata(
+ imageUrlService.getItemImageUrl(
+ item,
+ ImageType.PRIMARY,
+ useSeriesForPrimary = true,
+ ),
+ ),
+ ).setUri(mediaUrl.toUri())
.setSubtitleConfigurations(listOfNotNull(externalSubtitle))
.apply {
when (source.container) {
@@ -960,15 +975,19 @@ class PlaybackViewModel
}
}
+ // Variables for tracking segment state
private var segmentJob: Job? = null
+ private val autoSkippedSegments = mutableSetOf()
+ private val outroShownSegments = mutableSetOf()
/**
* Cancels listening for segments and clears current segment state
*/
- private suspend fun resetSegmentState() {
+ private fun resetSegmentState() {
segmentJob?.cancel()
autoSkippedSegments.clear()
- currentSegment.setValueOnMain(null)
+ outroShownSegments.clear()
+ currentSegment.value = null
}
/**
@@ -991,20 +1010,27 @@ class PlaybackViewModel
it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks
}
if (currentSegment != null &&
- currentSegment.itemId == this@PlaybackViewModel.itemId &&
- autoSkippedSegments.add(currentSegment.id)
+ currentSegment.itemId == this@PlaybackViewModel.itemId
) {
- Timber.d(
- "Found media segment for %s: %s, %s",
- currentSegment.itemId,
- currentSegment.id,
- currentSegment.type,
- )
+ if (currentSegment.id !=
+ this@PlaybackViewModel
+ .currentSegment.value
+ ?.segment
+ ?.id
+ ) {
+ Timber.d(
+ "Found media segment for %s: %s, %s",
+ currentSegment.itemId,
+ currentSegment.id,
+ currentSegment.type,
+ )
+ }
val playlist = this@PlaybackViewModel.playlist.value
if (currentSegment.type == MediaSegmentType.OUTRO &&
prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS &&
- playlist != null && playlist.hasNext()
+ playlist != null && playlist.hasNext() &&
+ outroShownSegments.add(currentSegment.id)
) {
val nextItem = playlist.peek()
Timber.v("Setting next up during outro to ${nextItem?.id}")
@@ -1024,13 +1050,21 @@ class PlaybackViewModel
withContext(Dispatchers.Main) {
when (behavior) {
SkipSegmentBehavior.AUTO_SKIP -> {
- this@PlaybackViewModel.currentSegment.value = null
- player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1)
+ if (autoSkippedSegments.add(currentSegment.id)) {
+ onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) }
+ }
+ this@PlaybackViewModel.currentSegment.update {
+ MediaSegmentState(currentSegment, true)
+ }
}
SkipSegmentBehavior.ASK_TO_SKIP -> {
- this@PlaybackViewModel.currentSegment.value =
- currentSegment
+ this@PlaybackViewModel.currentSegment.update {
+ MediaSegmentState(
+ currentSegment,
+ autoSkippedSegments.contains(currentSegment.id),
+ )
+ }
}
else -> {
@@ -1049,6 +1083,28 @@ class PlaybackViewModel
}
}
+ fun updateSegment(
+ segmentId: UUID?,
+ dismissed: Boolean,
+ ) {
+ viewModelScope.launchDefault {
+ val segment = currentSegment.value?.segment
+ if (segment != null && segment.id == segmentId) {
+ autoSkippedSegments.add(segment.id)
+ if (dismissed) {
+ currentSegment.update {
+ it?.copy(interacted = true)
+ }
+ } else {
+ currentSegment.update {
+ null
+ }
+ onMain { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds + 1) }
+ }
+ }
+ }
+ }
+
private fun listenForTranscodeReason(): Job =
viewModelScope.launchIO {
currentPlayback.collectLatest {
@@ -1376,6 +1432,10 @@ class PlaybackViewModel
}
}
}
+
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ screensaverService.keepScreenOn(isPlaying)
+ }
}
data class PlayerState(
@@ -1383,3 +1443,8 @@ data class PlayerState(
val backend: PlayerBackend,
val assHandler: AssHandler?,
)
+
+data class MediaSegmentState(
+ val segment: MediaSegmentDto,
+ val interacted: Boolean,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt
new file mode 100644
index 00000000..6925d5cc
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt
@@ -0,0 +1,52 @@
+package com.github.damontecres.wholphin.ui.playback
+
+internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 8
+
+/**
+ * Shared seek acceleration profile for hold-to-seek behavior.
+ * Keep this in sync anywhere directional key repeat seeking is handled.
+ */
+fun calculateSeekAccelerationMultiplier(
+ repeatCount: Int,
+ durationMs: Long,
+): Int {
+ if (repeatCount <= 0 || durationMs <= 0L) return 1
+
+ // Repeat cadence varies by device. Scaling down by 3 keeps ramp-up closer to multi-second holds.
+ val scaledRepeatCount = repeatCount / 3
+ if (scaledRepeatCount <= 0) return 1
+
+ val durationMinutes = durationMs / 60_000L
+ return when {
+ durationMinutes < 30 -> {
+ if (scaledRepeatCount < 30) 1 else 2
+ }
+
+ durationMinutes < 90 -> {
+ when {
+ scaledRepeatCount < 13 -> 1
+ scaledRepeatCount < 50 -> 2
+ scaledRepeatCount < 75 -> 3
+ else -> 4
+ }
+ }
+
+ durationMinutes < 150 -> {
+ when {
+ scaledRepeatCount < 20 -> 1
+ scaledRepeatCount < 40 -> 2
+ scaledRepeatCount < 60 -> 4
+ else -> 6
+ }
+ }
+
+ else -> {
+ when {
+ scaledRepeatCount < 20 -> 1
+ scaledRepeatCount < 40 -> 3
+ scaledRepeatCount < 60 -> 6
+ else -> 10
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt
index d1db4144..92c6114b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt
@@ -46,7 +46,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
-import com.github.damontecres.wholphin.ui.handleDPadKeyEvents
import kotlinx.coroutines.FlowPreview
import kotlin.time.Duration
@@ -80,15 +79,16 @@ fun SteppedSeekBarImpl(
enabled = enabled,
progress = progressToUse,
bufferedProgress = bufferedProgress,
- onLeft = {
+ durationMs = durationMs,
+ onLeft = { multiplier ->
controllerViewState.pulseControls()
- seekProgress = (progressToUse - offset).coerceAtLeast(0f)
+ seekProgress = (progressToUse - offset * multiplier).coerceAtLeast(0f)
hasSeeked = true
seek(seekProgress)
},
- onRight = {
+ onRight = { multiplier ->
controllerViewState.pulseControls()
- seekProgress = (progressToUse + offset).coerceAtMost(1f)
+ seekProgress = (progressToUse + offset * multiplier).coerceAtMost(1f)
hasSeeked = true
seek(seekProgress)
},
@@ -126,16 +126,19 @@ fun IntervalSeekBarImpl(
enabled = enabled,
progress = (progressToUse.toDouble() / durationMs).toFloat(),
bufferedProgress = bufferedProgress,
- onLeft = {
+ durationMs = durationMs,
+ onLeft = { multiplier ->
controllerViewState.pulseControls()
- seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L)
+ seekPositionMs =
+ (progressToUse - seekBack.inWholeMilliseconds * multiplier).coerceAtLeast(0L)
hasSeeked = true
onSeek(seekPositionMs)
},
- onRight = {
+ onRight = { multiplier ->
controllerViewState.pulseControls()
seekPositionMs =
- (progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs)
+ (progressToUse + seekForward.inWholeMilliseconds * multiplier)
+ .coerceAtMost(durationMs)
hasSeeked = true
onSeek(seekPositionMs)
},
@@ -148,8 +151,9 @@ fun IntervalSeekBarImpl(
fun SeekBarDisplay(
progress: Float,
bufferedProgress: Float,
- onLeft: () -> Unit,
- onRight: () -> Unit,
+ durationMs: Long,
+ onLeft: (Int) -> Unit,
+ onRight: (Int) -> Unit,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
enabled: Boolean = true,
@@ -158,14 +162,13 @@ fun SeekBarDisplay(
val onSurface = MaterialTheme.colorScheme.onSurface
val isFocused by interactionSource.collectIsFocusedAsState()
+ var leftHandledByRepeat by remember { mutableStateOf(false) }
+ var rightHandledByRepeat by remember { mutableStateOf(false) }
val animatedIndicatorHeight by animateDpAsState(
targetValue = 6.dp.times((if (isFocused) 2f else 1f)),
)
- Column(
- modifier = modifier,
- verticalArrangement = Arrangement.spacedBy(16.dp),
- ) {
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Canvas(
modifier =
Modifier
@@ -173,24 +176,71 @@ fun SeekBarDisplay(
.height(animatedIndicatorHeight)
.padding(horizontal = 4.dp)
.onPreviewKeyEvent { event ->
- val trigger =
- event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0
when (event.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
- if (trigger) onLeft.invoke()
+ when (event.type) {
+ KeyEventType.KeyDown -> {
+ val repeatCount = event.nativeKeyEvent.repeatCount
+ if (repeatCount > 0) {
+ leftHandledByRepeat = true
+ onLeft.invoke(
+ calculateSeekAccelerationMultiplier(
+ repeatCount = repeatCount,
+ durationMs = durationMs,
+ ),
+ )
+ } else {
+ leftHandledByRepeat = false
+ }
+ }
+
+ KeyEventType.KeyUp -> {
+ if (!leftHandledByRepeat) {
+ onLeft.invoke(1)
+ }
+ leftHandledByRepeat = false
+ }
+
+ else -> {
+ return@onPreviewKeyEvent false
+ }
+ }
return@onPreviewKeyEvent true
}
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
- if (trigger) onRight.invoke()
+ when (event.type) {
+ KeyEventType.KeyDown -> {
+ val repeatCount = event.nativeKeyEvent.repeatCount
+ if (repeatCount > 0) {
+ rightHandledByRepeat = true
+ onRight.invoke(
+ calculateSeekAccelerationMultiplier(
+ repeatCount = repeatCount,
+ durationMs = durationMs,
+ ),
+ )
+ } else {
+ rightHandledByRepeat = false
+ }
+ }
+
+ KeyEventType.KeyUp -> {
+ if (!rightHandledByRepeat) {
+ onRight.invoke(1)
+ }
+ rightHandledByRepeat = false
+ }
+
+ else -> {
+ return@onPreviewKeyEvent false
+ }
+ }
return@onPreviewKeyEvent true
}
}
false
- }.handleDPadKeyEvents(
- onLeft = onLeft,
- onRight = onRight,
- ).focusable(enabled = enabled, interactionSource = interactionSource),
+ }.focusable(enabled = enabled, interactionSource = interactionSource),
onDraw = {
val yOffset = size.height.div(2)
drawLine(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt
index 9b9bcf9c..1762f916 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt
@@ -43,7 +43,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
import java.io.File
-import java.util.SortedSet
@Suppress("UNCHECKED_CAST")
@Composable
@@ -226,7 +225,7 @@ fun ComposablePreference(
}
is AppMultiChoicePreference<*, *> -> {
- val values = stringArrayResource(preference.displayValues).toSortedSet()
+ val values = stringArrayResource(preference.displayValues)
val summary =
preference.summary?.let { stringResource(it) }
?: preference.summary(context, value)
@@ -237,12 +236,15 @@ fun ComposablePreference(
list
}
MultiChoicePreference(
- possibleValues = values as SortedSet,
+ possibleValues = preference.allValues,
selectedValues = selectedValues,
title = title,
summary = summary,
onValueChange = {
- onValueChange.invoke(selectedValues.toList() as T)
+ onValueChange.invoke(it.toList() as T)
+ },
+ valueDisplay = { index, _ ->
+ Text(values[index])
},
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt
index a4686b10..bb34207f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt
@@ -19,12 +19,12 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup
fun MultiChoicePreference(
title: String,
summary: String?,
- possibleValues: Set,
+ possibleValues: List,
selectedValues: Set,
onValueChange: (List) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
- valueDisplay: @Composable (item: T) -> Unit = { Text(it.toString()) },
+ valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) },
) {
// val values = stringArrayResource(preference.displayValues).toList()
// val summary =
@@ -59,7 +59,7 @@ fun MultiChoicePreference(
items =
possibleValues.mapIndexed { index, item ->
DialogItem(
- headlineContent = { valueDisplay.invoke(item) },
+ headlineContent = { valueDisplay.invoke(index, item) },
trailingContent = {
Switch(
checked = selectedValues.contains(item),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt
new file mode 100644
index 00000000..afdc93a1
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt
@@ -0,0 +1,365 @@
+package com.github.damontecres.wholphin.ui.preferences
+
+import android.content.Context
+import androidx.annotation.StringRes
+import androidx.compose.foundation.interaction.MutableInteractionSource
+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.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+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.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.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.datastore.core.DataStore
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Switch
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.ServerPreferencesDao
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
+import com.github.damontecres.wholphin.data.model.NavPinType
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.services.BackdropService
+import com.github.damontecres.wholphin.services.NavDrawerItemState
+import com.github.damontecres.wholphin.services.NavDrawerService
+import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.SeerrServerRepository
+import com.github.damontecres.wholphin.ui.FontAwesome
+import com.github.damontecres.wholphin.ui.PreviewTvSpec
+import com.github.damontecres.wholphin.ui.components.BasicDialog
+import com.github.damontecres.wholphin.ui.components.Button
+import com.github.damontecres.wholphin.ui.launchDefault
+import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
+import com.github.damontecres.wholphin.ui.theme.WholphinTheme
+import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.RememberTabManager
+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.firstOrNull
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.withContext
+import org.jellyfin.sdk.api.client.ApiClient
+import javax.inject.Inject
+
+data class NavDrawerPin(
+ val id: String,
+ val title: String,
+ val pinned: Boolean,
+ val item: NavDrawerItem,
+) {
+ companion object {
+ fun create(
+ context: Context,
+ items: Map,
+ ) {
+ items.map { (item, pinned) ->
+ NavDrawerPin(item.id, item.name(context), pinned, item)
+ }
+ }
+ }
+}
+
+enum class MoveDirection {
+ UP,
+ DOWN,
+}
+
+private fun List.move(
+ direction: MoveDirection,
+ index: Int,
+): List =
+ toMutableList().apply {
+ if (direction == MoveDirection.DOWN) {
+ val down = this[index]
+ val up = this[index + 1]
+ set(index, up)
+ set(index + 1, down)
+ } else {
+ val up = this[index]
+ val down = this[index - 1]
+ set(index - 1, up)
+ set(index, down)
+ }
+ }
+
+@Composable
+fun NavDrawerPreference(
+ title: String,
+ summary: String?,
+ modifier: Modifier = Modifier,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ viewModel: NavDrawerPreferencesViewModel = hiltViewModel(),
+) {
+ val items by viewModel.state.collectAsState()
+ var showDialog by remember { mutableStateOf(false) }
+ ClickPreference(
+ title = title,
+ summary = summary,
+ onClick = { showDialog = true },
+ interactionSource = interactionSource,
+ modifier = modifier,
+ )
+ if (showDialog) {
+ NavDrawerPreferenceDialog(
+ items = items,
+ onDismissRequest = {
+ viewModel.save()
+ showDialog = false
+ },
+ onClick = { index ->
+ val newItems =
+ items.toMutableList().apply {
+ set(index, items[index].let { it.copy(pinned = !it.pinned) })
+ }
+ viewModel.update(newItems)
+ },
+ onMoveUp = { index ->
+ viewModel.update(items.move(MoveDirection.UP, index))
+ },
+ onMoveDown = { index ->
+ viewModel.update(items.move(MoveDirection.DOWN, index))
+ },
+ )
+ }
+}
+
+@Composable
+fun NavDrawerPreferenceDialog(
+ items: List,
+ onDismissRequest: () -> Unit,
+ onClick: (Int) -> Unit,
+ onMoveUp: (Int) -> Unit,
+ onMoveDown: (Int) -> Unit,
+) {
+ BasicDialog(
+ onDismissRequest = onDismissRequest,
+ elevation = 3.dp,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .padding(16.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.nav_drawer_pins),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+ val listState = rememberLazyListState()
+ LazyColumn(
+ state = listState,
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ ) {
+ itemsIndexed(items, key = { _, item -> item.id }) { index, item ->
+ NavDrawerPreferenceListItem(
+ title = item.title,
+ pinned = item.pinned,
+ moveUpAllowed = index > 0,
+ moveDownAllowed = index < items.lastIndex,
+ onClick = { onClick.invoke(index) },
+ onMoveUp = { onMoveUp.invoke(index) },
+ onMoveDown = { onMoveDown.invoke(index) },
+ modifier = Modifier.animateItem(),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun NavDrawerPreferenceListItem(
+ title: String,
+ pinned: Boolean,
+ moveUpAllowed: Boolean,
+ moveDownAllowed: Boolean,
+ onClick: () -> Unit,
+ onMoveUp: () -> Unit,
+ onMoveDown: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .heightIn(min = 40.dp, max = 88.dp),
+ ) {
+ ListItem(
+ selected = false,
+ headlineContent = {
+ Text(
+ text = title,
+ )
+ },
+ trailingContent = {
+ Switch(
+ checked = pinned,
+ onCheckedChange = {
+ onClick.invoke()
+ },
+ )
+ },
+ onClick = onClick,
+ modifier = Modifier.weight(1f),
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.wrapContentWidth(),
+ ) {
+ MoveButton(R.string.fa_caret_up, moveUpAllowed, onMoveUp)
+ MoveButton(R.string.fa_caret_down, moveDownAllowed, onMoveDown)
+ }
+ }
+ }
+}
+
+@Composable
+private fun MoveButton(
+ @StringRes icon: Int,
+ enabled: Boolean,
+ onClick: () -> Unit,
+) = Button(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = Modifier.size(32.dp),
+) {
+ Text(
+ text = stringResource(icon),
+ fontSize = 16.sp,
+ fontFamily = FontAwesome,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+}
+
+@PreviewTvSpec
+@Composable
+fun NavDrawerPreferenceListItemPreview() {
+ WholphinTheme {
+ NavDrawerPreferenceListItem(
+ title = "Movies",
+ pinned = true,
+ moveUpAllowed = true,
+ moveDownAllowed = true,
+ onClick = {},
+ onMoveUp = {},
+ onMoveDown = { },
+ modifier = Modifier.width(360.dp),
+ )
+ }
+}
+
+@HiltViewModel
+class NavDrawerPreferencesViewModel
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
+ val preferenceDataStore: DataStore,
+ val navigationManager: NavigationManager,
+ val backdropService: BackdropService,
+ private val rememberTabManager: RememberTabManager,
+ private val serverRepository: ServerRepository,
+ private val navDrawerService: NavDrawerService,
+ private val serverPreferencesDao: ServerPreferencesDao,
+ private val seerrServerRepository: SeerrServerRepository,
+ ) : ViewModel() {
+ val state = MutableStateFlow>(listOf())
+
+ init {
+ viewModelScope.launchDefault {
+ val state = navDrawerService.state.value
+ val user = serverRepository.currentUser.value
+ val seerr = seerrServerRepository.active.firstOrNull()
+ if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) {
+ return@launchDefault
+ }
+ val navDrawerPins =
+ withContext(Dispatchers.IO) {
+ serverPreferencesDao
+ .getNavDrawerPinnedItems(user)
+ .associateBy { it.itemId }
+ }
+ val allItems = state.let { it.items + it.moreItems }
+ val pins =
+ allItems
+ .sortedBy {
+ navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE
+ }.mapNotNull {
+ if (!seerr && it is NavDrawerItem.Discover) {
+ null
+ } else {
+ // Assume pinned if unknown
+ val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED
+ NavDrawerPin(
+ it.id,
+ it.name(context),
+ pinned == NavPinType.PINNED,
+ it,
+ )
+ }
+ }
+ this@NavDrawerPreferencesViewModel.state.value = pins
+ }
+ }
+
+ fun update(items: List) {
+ state.update { items }
+ }
+
+ fun save() {
+ viewModelScope.launchIO(ExceptionHandler(true)) {
+ serverRepository.currentUser.value?.let { user ->
+ serverRepository.currentUserDto.value?.let { userDto ->
+ if (user.id == userDto.id) {
+ val toSave =
+ state.value.mapIndexed { index, item ->
+ NavDrawerPinnedItem(
+ user.rowId,
+ item.id,
+ if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED,
+ index,
+ )
+ }
+ serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray())
+ navDrawerService.updateNavDrawer(user, userDto)
+ } else {
+ throw IllegalStateException("User IDs do not match")
+ }
+ }
+ }
+ }
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt
index f84d3b89..454a0266 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt
@@ -2,21 +2,20 @@ package com.github.damontecres.wholphin.ui.preferences
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.preferences.AppPreference
-import com.github.damontecres.wholphin.preferences.AppPreferences
import kotlinx.serialization.Serializable
/**
* A group of preferences
*/
-data class PreferenceGroup(
+data class PreferenceGroup(
@param:StringRes val title: Int,
- val preferences: List>,
- val conditionalPreferences: List = listOf(),
+ val preferences: List>,
+ val conditionalPreferences: List> = listOf(),
)
-data class ConditionalPreferences(
- val condition: (AppPreferences) -> Boolean,
- val preferences: List>,
+data class ConditionalPreferences(
+ val condition: (T) -> Boolean,
+ val preferences: List>,
)
/**
@@ -34,10 +33,9 @@ sealed interface PreferenceValidation {
enum class PreferenceScreenOption {
BASIC,
ADVANCED,
- USER_INTERFACE,
- SUBTITLES,
EXO_PLAYER,
MPV,
+ SCREENSAVER,
;
companion object {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
index 8fedf3b6..729e7085 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
@@ -11,8 +11,10 @@ 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.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@@ -48,10 +50,12 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
import com.github.damontecres.wholphin.preferences.MpvPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
+import com.github.damontecres.wholphin.preferences.ScreensaverPreference
import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences
-import com.github.damontecres.wholphin.preferences.uiPreferences
+import com.github.damontecres.wholphin.preferences.screensaverPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
+import com.github.damontecres.wholphin.services.SeerrConnectionStatus
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.ifElse
@@ -60,7 +64,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
-import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
@@ -91,10 +94,10 @@ fun PreferencesContent(
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
var showPinFlow by remember { mutableStateOf(false) }
- val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
- val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
+ val seerrConnection by viewModel.seerrConnection.collectAsState()
var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) }
+ var showQuickConnectDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect {
@@ -125,19 +128,17 @@ fun PreferencesContent(
when (preferenceScreenOption) {
PreferenceScreenOption.BASIC -> basicPreferences
PreferenceScreenOption.ADVANCED -> advancedPreferences
- PreferenceScreenOption.USER_INTERFACE -> uiPreferences
- PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
PreferenceScreenOption.MPV -> MpvPreferences
+ PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
}
val screenTitle =
when (preferenceScreenOption) {
PreferenceScreenOption.BASIC -> R.string.settings
PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
- PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface
- PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
PreferenceScreenOption.MPV -> R.string.mpv_options
+ PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
}
var visible by remember { mutableStateOf(false) }
@@ -171,291 +172,339 @@ fun PreferencesContent(
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
- LazyColumn(
- state = state,
- horizontalAlignment = Alignment.Start,
- verticalArrangement = Arrangement.spacedBy(0.dp),
- contentPadding = PaddingValues(16.dp),
+ Column(
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
) {
- stickyHeader {
- Text(
- text = stringResource(screenTitle),
- style = MaterialTheme.typography.headlineSmall,
- color = MaterialTheme.colorScheme.onSurface,
- textAlign = TextAlign.Center,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp),
- )
- }
- if (UpdateChecker.ACTIVE &&
- preferenceScreenOption == PreferenceScreenOption.BASIC &&
- preferences.autoCheckForUpdates &&
- updateAvailable
+ Text(
+ text = stringResource(screenTitle),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ )
+ LazyColumn(
+ state = state,
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ contentPadding = PaddingValues(16.dp),
+ modifier = Modifier.fillMaxSize(),
) {
- item {
- val updateFocusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) {
- if (focusedIndex.first == 0 && focusedIndex.second == 0) {
- // Only re-focus if the user hasn't moved
- updateFocusRequester.tryRequestFocus()
- }
- }
- ClickPreference(
- title = stringResource(R.string.install_update),
- onClick = {
- if (movementSounds) playOnClickSound(context)
- viewModel.navigationManager.navigateTo(Destination.UpdateApp)
- },
- summary = release?.version?.toString(),
- modifier =
- Modifier
- .focusRequester(updateFocusRequester)
- .playSoundOnFocus(movementSounds),
- )
- }
- }
- prefList.forEachIndexed { groupIndex, group ->
- item {
- Text(
- text = stringResource(group.title),
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface,
- textAlign = TextAlign.Start,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(top = 8.dp, bottom = 4.dp),
- )
- }
- val groupPreferences =
- group.preferences +
- group.conditionalPreferences
- .filter { it.condition.invoke(preferences) }
- .map { it.preferences }
- .flatten()
- groupPreferences.forEachIndexed { prefIndex, pref ->
- pref as AppPreference
+ if (UpdateChecker.ACTIVE &&
+ preferenceScreenOption == PreferenceScreenOption.BASIC &&
+ preferences.autoCheckForUpdates &&
+ updateAvailable
+ ) {
item {
- val interactionSource = remember { MutableInteractionSource() }
- val focused = interactionSource.collectIsFocusedAsState().value
- LaunchedEffect(focused) {
- if (focused) {
- focusedIndex = Pair(groupIndex, prefIndex)
- if (movementSounds) playOnClickSound(context)
- onFocus.invoke(groupIndex, prefIndex)
+ val updateFocusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ if (focusedIndex.first == 0 && focusedIndex.second == 0) {
+ // Only re-focus if the user hasn't moved
+ updateFocusRequester.tryRequestFocus()
}
}
- when (pref) {
- AppPreference.InstalledVersion -> {
- var clickCount by remember { mutableIntStateOf(0) }
- ClickPreference(
- title = stringResource(R.string.installed_version),
- onClick = {
- if (movementSounds) playOnClickSound(context)
- if (clickCount++ >= 2) {
- clickCount = 0
- viewModel.navigationManager.navigateTo(Destination.Debug)
- }
- },
- summary = installedVersion.toString(),
- interactionSource = interactionSource,
- modifier =
- Modifier
- .ifElse(
- groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
- Modifier.focusRequester(focusRequester),
- ),
- )
- }
-
- AppPreference.Update -> {
- ClickPreference(
- title =
- if (release != null && updateAvailable) {
- stringResource(R.string.install_update)
- } else if (!preferences.autoCheckForUpdates && release == null) {
- stringResource(R.string.check_for_updates)
- } else {
- stringResource(R.string.no_update_available)
- },
- onClick = {
- if (movementSounds) playOnClickSound(context)
- if (release != null && updateAvailable) {
- release?.let {
- viewModel.navigationManager.navigateTo(Destination.UpdateApp)
- }
- } else {
- updateVM.init(preferences.updateUrl)
- }
- },
- onLongClick = {
- if (movementSounds) playOnClickSound(context)
- viewModel.navigationManager.navigateTo(Destination.UpdateApp)
- },
- summary =
- if (updateAvailable) {
- release?.version?.toString()
- } else {
- null
- },
- interactionSource = interactionSource,
- modifier =
- Modifier
- .ifElse(
- groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
- Modifier.focusRequester(focusRequester),
- ),
- )
- }
-
- AppPreference.ClearImageCache -> {
- val summary =
- remember(cacheUsage) {
- cacheUsage.let {
- val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT
- val memoryUsedMB =
- it.imageMemoryUsed / AppPreference.MEGA_BIT
- val memoryMaxMB =
- it.imageMemoryMax / AppPreference.MEGA_BIT
- "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb"
- }
- }
- ClickPreference(
- title = stringResource(pref.title),
- onClick = {
- SingletonImageLoader.get(context).let {
- it.memoryCache?.clear()
- it.diskCache?.clear()
- updateCache = true
- }
- },
- modifier = Modifier,
- summary = summary,
- onLongClick = {},
- interactionSource = interactionSource,
- )
- }
-
- AppPreference.UserPinnedNavDrawerItems -> {
- val selectedItems =
- navDrawerPins.keys.mapNotNull {
- if (navDrawerPins[it] ?: false) it else null
- }
- MultiChoicePreference(
- title = stringResource(pref.title),
- summary = pref.summary(context, null),
- possibleValues = navDrawerPins.keys,
- selectedValues = selectedItems.toSet(),
- onValueChange = { newSelectedItems ->
- viewModel.updatePins(newSelectedItems)
- },
- ) {
- Text(it.name(context))
+ ClickPreference(
+ title = stringResource(R.string.install_update),
+ onClick = {
+ if (movementSounds) playOnClickSound(context)
+ viewModel.navigationManager.navigateTo(Destination.UpdateApp)
+ },
+ summary = release?.version?.toString(),
+ modifier =
+ Modifier
+ .focusRequester(updateFocusRequester)
+ .playSoundOnFocus(movementSounds),
+ )
+ }
+ }
+ prefList.forEachIndexed { groupIndex, group ->
+ item {
+ Text(
+ text = stringResource(group.title),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Start,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp, bottom = 4.dp),
+ )
+ }
+ val groupPreferences =
+ group.preferences +
+ group.conditionalPreferences
+ .filter { it.condition.invoke(preferences) }
+ .map { it.preferences }
+ .flatten()
+ groupPreferences.forEachIndexed { prefIndex, pref ->
+ pref as AppPreference
+ item {
+ val interactionSource = remember { MutableInteractionSource() }
+ val focused = interactionSource.collectIsFocusedAsState().value
+ LaunchedEffect(focused) {
+ if (focused) {
+ focusedIndex = Pair(groupIndex, prefIndex)
+ if (movementSounds) playOnClickSound(context)
+ onFocus.invoke(groupIndex, prefIndex)
}
}
-
- AppPreference.SendAppLogs -> {
- ClickPreference(
- title = stringResource(pref.title),
- onClick = {
- viewModel.sendAppLogs()
- },
- modifier = Modifier,
- summary = pref.summary(context, null),
- onLongClick = {},
- interactionSource = interactionSource,
- )
- }
-
- SubtitleSettings.Reset -> {
- ClickPreference(
- title = stringResource(pref.title),
- onClick = {
- viewModel.resetSubtitleSettings()
- },
- modifier = Modifier,
- summary = pref.summary(context, null),
- onLongClick = {},
- interactionSource = interactionSource,
- )
- }
-
- AppPreference.RequireProfilePin -> {
- SwitchPreference(
- title = stringResource(pref.title),
- value = currentUser?.pin.isNotNullOrBlank(),
- onClick = {
- showPinFlow = true
- },
- summaryOn = stringResource(R.string.enabled),
- summaryOff = null,
- modifier = Modifier,
- )
- }
-
- AppPreference.SeerrIntegration -> {
- ClickPreference(
- title = stringResource(pref.title),
- onClick = {
- if (seerrIntegrationEnabled) {
- seerrDialogMode = SeerrDialogMode.Remove
- } else {
- seerrVm.resetStatus()
- seerrDialogMode = SeerrDialogMode.Add
- }
- },
- modifier = Modifier,
- summary =
- if (seerrIntegrationEnabled) {
- stringResource(R.string.enabled)
- } else {
- null
- },
- onLongClick = {},
- interactionSource = interactionSource,
- )
- }
-
- else -> {
- val value = pref.getter.invoke(preferences)
- ComposablePreference(
- preference = pref,
- value = value,
- onNavigate = viewModel.navigationManager::navigateTo,
- onValueChange = { newValue ->
- val validation = pref.validate(newValue)
- when (validation) {
- is PreferenceValidation.Invalid -> {
- // TODO?
- Toast
- .makeText(
- context,
- validation.message,
- Toast.LENGTH_SHORT,
- ).show()
+ when (pref) {
+ AppPreference.InstalledVersion -> {
+ var clickCount by remember { mutableIntStateOf(0) }
+ ClickPreference(
+ title = stringResource(R.string.installed_version),
+ onClick = {
+ if (movementSounds) playOnClickSound(context)
+ if (clickCount++ >= 2) {
+ clickCount = 0
+ viewModel.navigationManager.navigateTo(Destination.Debug)
}
+ },
+ summary = installedVersion.toString(),
+ interactionSource = interactionSource,
+ modifier =
+ Modifier
+ .ifElse(
+ groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
+ Modifier.focusRequester(focusRequester),
+ ),
+ )
+ }
- PreferenceValidation.Valid -> {
- scope.launch(ExceptionHandler()) {
- preferences =
- viewModel.preferenceDataStore.updateData { prefs ->
- pref.setter(prefs, newValue)
- }
+ AppPreference.Update -> {
+ ClickPreference(
+ title =
+ if (release != null && updateAvailable) {
+ stringResource(R.string.install_update)
+ } else if (!preferences.autoCheckForUpdates && release == null) {
+ stringResource(R.string.check_for_updates)
+ } else {
+ stringResource(R.string.no_update_available)
+ },
+ onClick = {
+ if (movementSounds) playOnClickSound(context)
+ if (release != null && updateAvailable) {
+ release?.let {
+ viewModel.navigationManager.navigateTo(
+ Destination.UpdateApp,
+ )
+ }
+ } else {
+ updateVM.init(preferences.updateUrl)
+ }
+ },
+ onLongClick = {
+ if (movementSounds) playOnClickSound(context)
+ viewModel.navigationManager.navigateTo(Destination.UpdateApp)
+ },
+ summary =
+ if (updateAvailable) {
+ release?.version?.toString()
+ } else {
+ null
+ },
+ interactionSource = interactionSource,
+ modifier =
+ Modifier
+ .ifElse(
+ groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
+ Modifier.focusRequester(focusRequester),
+ ),
+ )
+ }
+
+ AppPreference.ClearImageCache -> {
+ val summary =
+ remember(cacheUsage) {
+ cacheUsage.let {
+ val diskMB =
+ it.imageDiskUsed / AppPreference.MEGA_BIT
+ val memoryUsedMB =
+ it.imageMemoryUsed / AppPreference.MEGA_BIT
+ val memoryMaxMB =
+ it.imageMemoryMax / AppPreference.MEGA_BIT
+ "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb"
+ }
+ }
+ ClickPreference(
+ title = stringResource(pref.title),
+ onClick = {
+ SingletonImageLoader.get(context).let {
+ it.memoryCache?.clear()
+ it.diskCache?.clear()
+ updateCache = true
+ }
+ },
+ modifier = Modifier,
+ summary = summary,
+ onLongClick = {},
+ interactionSource = interactionSource,
+ )
+ }
+
+ AppPreference.UserPinnedNavDrawerItems -> {
+ NavDrawerPreference(
+ title = stringResource(pref.title),
+ summary = pref.summary(context, null),
+ modifier = Modifier,
+ interactionSource = interactionSource,
+ )
+ }
+
+ AppPreference.SendAppLogs -> {
+ ClickPreference(
+ title = stringResource(pref.title),
+ onClick = {
+ viewModel.sendAppLogs()
+ },
+ modifier = Modifier,
+ summary = pref.summary(context, null),
+ onLongClick = {},
+ interactionSource = interactionSource,
+ )
+ }
+
+ SubtitleSettings.Reset -> {
+ ClickPreference(
+ title = stringResource(pref.title),
+ onClick = {
+ viewModel.resetSubtitleSettings()
+ },
+ modifier = Modifier,
+ summary = pref.summary(context, null),
+ onLongClick = {},
+ interactionSource = interactionSource,
+ )
+ }
+
+ AppPreference.RequireProfilePin -> {
+ SwitchPreference(
+ title = stringResource(pref.title),
+ value = currentUser?.pin.isNotNullOrBlank(),
+ onClick = {
+ showPinFlow = true
+ },
+ summaryOn = stringResource(R.string.enabled),
+ summaryOff = null,
+ modifier = Modifier,
+ )
+ }
+
+ AppPreference.SeerrIntegration -> {
+ ClickPreference(
+ title = stringResource(pref.title),
+ onClick = {
+ seerrDialogMode =
+ when (val conn = seerrConnection) {
+ is SeerrConnectionStatus.Error -> {
+ SeerrDialogMode.Error(
+ conn.serverUrl,
+ conn.ex,
+ )
+ }
+
+ SeerrConnectionStatus.NotConfigured -> {
+ SeerrDialogMode.Add
+ }
+
+ is SeerrConnectionStatus.Success -> {
+ SeerrDialogMode.Remove(
+ conn.current.server.url,
+ )
+ }
+ }
+ },
+ modifier = Modifier,
+ summary =
+ when (seerrConnection) {
+ is SeerrConnectionStatus.Error -> {
+ stringResource(R.string.voice_error_server)
+ }
+
+ SeerrConnectionStatus.NotConfigured -> {
+ stringResource(
+ R.string.add_server,
+ )
+ }
+
+ is SeerrConnectionStatus.Success -> {
+ stringResource(R.string.enabled)
+ }
+ },
+ onLongClick = {},
+ interactionSource = interactionSource,
+ )
+ }
+
+ AppPreference.QuickConnect -> {
+ ClickPreference(
+ title = stringResource(pref.title),
+ onClick = {
+ if (currentUser != null) {
+ viewModel.resetQuickConnectStatus()
+ showQuickConnectDialog = true
+ }
+ },
+ modifier = Modifier,
+ summary = pref.summary(context, null),
+ onLongClick = {},
+ interactionSource = interactionSource,
+ )
+ }
+
+ ScreensaverPreference.Start -> {
+ ClickPreference(
+ title = stringResource(pref.title),
+ onClick = {
+ viewModel.screensaverService.start()
+ },
+ modifier = Modifier,
+ summary = pref.summary(context, null),
+ onLongClick = {},
+ interactionSource = interactionSource,
+ )
+ }
+
+ else -> {
+ val value = pref.getter.invoke(preferences)
+ ComposablePreference(
+ preference = pref,
+ value = value,
+ onNavigate = viewModel.navigationManager::navigateTo,
+ onValueChange = { newValue ->
+ val validation = pref.validate(newValue)
+ when (validation) {
+ is PreferenceValidation.Invalid -> {
+ // TODO?
+ Toast
+ .makeText(
+ context,
+ validation.message,
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+
+ PreferenceValidation.Valid -> {
+ scope.launch(ExceptionHandler()) {
+ preferences =
+ viewModel.preferenceDataStore.updateData { prefs ->
+ pref.setter(prefs, newValue)
+ }
+ }
}
}
- }
- },
- interactionSource = interactionSource,
- modifier =
- Modifier
- .ifElse(
- groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
- Modifier.focusRequester(focusRequester),
- ),
- )
+ },
+ interactionSource = interactionSource,
+ modifier =
+ Modifier
+ .ifElse(
+ groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
+ Modifier.focusRequester(focusRequester),
+ ),
+ )
+ }
}
}
}
@@ -478,11 +527,11 @@ fun PreferencesContent(
)
}
}
- when (seerrDialogMode) {
- SeerrDialogMode.Remove -> {
+ when (val mode = seerrDialogMode) {
+ is SeerrDialogMode.Remove -> {
ConfirmDialog(
title = stringResource(R.string.remove_seerr_server),
- body = currentServer?.url ?: "",
+ body = mode.serverUrl,
onCancel = { seerrDialogMode = SeerrDialogMode.None },
onConfirm = {
seerrVm.removeServer()
@@ -505,13 +554,67 @@ fun PreferencesContent(
currentUsername = currentUser?.name,
status = status,
onSubmit = seerrVm::submitServer,
+ onResetStatus = seerrVm::resetStatus,
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
)
}
+ is SeerrDialogMode.Error -> {
+ val errorStr = stringResource(R.string.voice_error_server)
+ val body =
+ remember(mode) {
+ """
+ ${mode.serverUrl}
+
+ $errorStr: ${mode.ex.localizedMessage}
+ """.trimIndent()
+ }
+ ConfirmDialog(
+ title = stringResource(R.string.remove_seerr_server),
+ body = body,
+ onCancel = { seerrDialogMode = SeerrDialogMode.None },
+ onConfirm = {
+ seerrVm.removeServer()
+ seerrDialogMode = SeerrDialogMode.None
+ },
+ bodyColor = MaterialTheme.colorScheme.error,
+ )
+ }
+
SeerrDialogMode.None -> {}
}
}
+
+ if (showQuickConnectDialog) {
+ val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending)
+ val successMessage = stringResource(R.string.quick_connect_success)
+
+ LaunchedEffect(quickConnectStatus) {
+ when (val status = quickConnectStatus) {
+ LoadingState.Success -> {
+ Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
+ showQuickConnectDialog = false
+ }
+
+ is LoadingState.Error -> {
+ val errorMessage = status.message ?: "Authorization failed"
+ Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
+ }
+
+ else -> {}
+ }
+ }
+
+ QuickConnectDialog(
+ onSubmit = { code ->
+ viewModel.authorizeQuickConnect(code)
+ },
+ onDismissRequest = {
+ viewModel.resetQuickConnectStatus()
+ showQuickConnectDialog = false
+ },
+ )
+ }
}
@Composable
@@ -526,9 +629,9 @@ fun PreferencesPage(
when (preferenceScreenOption) {
PreferenceScreenOption.BASIC,
PreferenceScreenOption.ADVANCED,
- PreferenceScreenOption.USER_INTERFACE,
PreferenceScreenOption.EXO_PLAYER,
PreferenceScreenOption.MPV,
+ PreferenceScreenOption.SCREENSAVER,
-> {
PreferencesContent(
initialPreferences,
@@ -539,12 +642,6 @@ fun PreferencesPage(
.align(Alignment.TopEnd),
)
}
-
- PreferenceScreenOption.SUBTITLES -> {
- SubtitleStylePage(
- initialPreferences,
- )
- }
}
}
}
@@ -555,10 +652,17 @@ data class CacheUsage(
val imageDiskUsed: Long,
)
-private sealed class SeerrDialogMode {
- data object None : SeerrDialogMode()
+private sealed interface SeerrDialogMode {
+ data object None : SeerrDialogMode
- data object Add : SeerrDialogMode()
+ data object Add : SeerrDialogMode
- data object Remove : SeerrDialogMode()
+ data class Remove(
+ val serverUrl: String,
+ ) : SeerrDialogMode
+
+ data class Error(
+ val serverUrl: String,
+ val ex: Exception,
+ ) : SeerrDialogMode
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
index 9973bbec..58f9a5e3 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
@@ -2,32 +2,26 @@ package com.github.damontecres.wholphin.ui.preferences
import android.content.Context
import androidx.datastore.core.DataStore
-import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
-import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
-import com.github.damontecres.wholphin.data.NavDrawerItemRepository
-import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
-import com.github.damontecres.wholphin.data.isPinned
import com.github.damontecres.wholphin.data.model.JellyfinUser
-import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
-import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO
-import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
-import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
@@ -42,64 +36,25 @@ class PreferencesViewModel
val preferenceDataStore: DataStore,
val navigationManager: NavigationManager,
val backdropService: BackdropService,
+ val screensaverService: ScreensaverService,
private val rememberTabManager: RememberTabManager,
private val serverRepository: ServerRepository,
- private val navDrawerItemRepository: NavDrawerItemRepository,
- private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
private val deviceInfo: DeviceInfo,
private val clientInfo: ClientInfo,
) : ViewModel(),
RememberTabManager by rememberTabManager {
- private lateinit var allNavDrawerItems: List
- val navDrawerPins = MutableLiveData